Introduction

SDKs & integrations

Official client libraries for the Puntjes API. They handle the OAuth2 token lifecycle, safe retries with idempotency keys, typed errors, and pagination, so your integration contains business logic, not HTTP plumbing. The experimental Delphi units are the exception: they are generated calls only.


Official packages

PackageForRequires
puntjes/php-sdkAny PHP application: WooCommerce/WordPress plugins, POS bridges, cron jobsPHP 8.1+
puntjes/laravelLaravel applicationsPHP 8.2+, Laravel 12–13
Puntjes.SdkAny .NET application: POS software, web shops, background services.NET 8 or .NET 10
com.github.PuntjesApp:puntjes-javaJava applications: POS software, web shop back ends, background servicesJava 17+
@puntjes/sdkNode.js servers: web shop back ends, serverless functions, POS bridgesNode 20+
puntjes-delphiDelphi and Free Pascal programs, experimentalmORMot 2.4

puntjes/laravel is a thin layer over the core SDK: config, a facade, and a cache-backed token store so your app performs one token grant per hour instead of one per request.

The PHP packages are on Packagist and follow SemVer. Both are at 2.0: pin them with ^2.0. A minor or patch release of puntjes/php-sdk brings a matching puntjes/laravel release by itself, with the same calls. A major release of the core waits until we have checked the Laravel package against it.

Registering a customer

The identifiers array accepts email and loyalty_card, and you can leave it out entirely so Puntjes issues the card itself. That is the shortest correct call and the one most integrations want.

A loyalty_card value you do send must be a card the vendor has printed and not yet handed to anyone. See Customer endpoints.

On puntjes/php-sdk below 1.0.0, neither of those works: the card-less call is refused before a request is made, and the SDK has no loyalty_card identifier type. Upgrade rather than working around it.


Plain PHP (any framework)

composer require puntjes/php-sdk:^2.0

The SDK talks PSR-18 and uses the HTTP client your project already has. It installs none. If you have none:

composer require guzzlehttp/guzzle

Create your credentials in the admin portal (see API Client Management). The secret is shown once.

use Puntjes\Generated\Customers\Lookup\LookupRequestBuilderGetRequestConfiguration as Lookup;
use Puntjes\Generated\Models\CreateTransactionData;
use Puntjes\Puntjes;

$puntjes = Puntjes::make(getenv('PUNTJES_CLIENT_ID'), getenv('PUNTJES_CLIENT_SECRET'));

// A customer scans their card at the till.
$customer = $puntjes->customers()->lookup()->get(new Lookup(
    queryParameters: Lookup::createQueryParameters(identifier: $scannedCard),
))->wait()->getData();

echo $customer->getWalletBalance();

// Record the purchase. Amounts are in cents.
$body = new CreateTransactionData();
$body->setIdentifier($scannedCard);
$body->setTotalAmount(4200);
$body->setIdempotencyKey('order-'.$orderId);

$transaction = $puntjes->transactions()->post($body)->wait()->getData();

echo $transaction->getPointsEarned();

Puntjes::make() returns a client generated from the Puntjes OpenAPI document, so it has every call the API has. A call follows the URL: ->customers()->byCustomer('42')->wallet()->get() is GET /customers/42/wallet.

Every call returns a promise. End it with ->wait(), then read the data part of the answer with ->getData(). A success answer without a data part gives null.

Authentication is handled for you: a client_credentials token is fetched on the first call, cached until it expires, and fetched again once if the API rejects it.

Errors

Every exception extends Puntjes\Exception\PuntjesException. An error answer from the API becomes a named exception, and errorCode() gives the error code as an enum:

use Puntjes\Exception\{ApiException, NotFoundException, ValidationException};
use Puntjes\Generated\Errors\ErrorCode;

try {
    $puntjes->redemptions()->post($body)->wait();
} catch (NotFoundException $e) {
    // 404: no such customer or reward
} catch (ValidationException $e) {
    $e->errorsFor('reward_id');
} catch (ApiException $e) {
    if ($e->errorCode() === ErrorCode::InsufficientBalance) {
        // ...
    }

    $e->requestId(); // quote this in a support ticket
}

For a code that is newer than your SDK, errorCode() is null and code() still gives the raw string. A 422 for a bad field is a ValidationException. A 422 for a business rule, such as INSUFFICIENT_BALANCE, is an ApiException.

Apple wallet pass

WalletPass::apple($puntjes, $customerId) returns the Apple pass (a .pkpass file) as a stream:

use Puntjes\WalletPass;

$pass = WalletPass::apple($puntjes, $customerId);

Upgrading from 1.x

2.0 is a new core, and the 1.x calls ($puntjes->customers->lookup(), Puntjes\Request\*) no longer exist. The upgrade guide lists every 1.x call next to its 2.0 form.

1.x gets security fixes for 6 months after 2.0.0 reaches Packagist. They land on the 1.x branch. To stay on 1.x, keep "puntjes/php-sdk": "^1.7" in your composer.json.


Laravel

composer require puntjes/laravel
PUNTJES_CLIENT_ID=your-client-id
PUNTJES_CLIENT_SECRET=your-client-secret
PUNTJES_BASE_URL=https://puntjes.app/api/v1

That is the whole setup. The service provider and facade are auto-discovered, and tokens are cached in your application's cache store.

use Puntjes\Generated\Customers\Lookup\LookupRequestBuilderGetRequestConfiguration as Lookup;
use Puntjes\Generated\Models\CreateTransactionData;
use Puntjes\Laravel\Facades\Puntjes;

$customer = Puntjes::customers()->lookup()->get(new Lookup(
    queryParameters: Lookup::createQueryParameters(identifier: $card),
))->wait()->getData();

$body = new CreateTransactionData();
$body->setIdentifier($card);
$body->setTotalAmount($order->total_cents);
$body->setIdempotencyKey('order-'.$order->id);

Puntjes::transactions()->post($body)->wait();

The facade forwards to the core's generated client, so every call above works the same way through the facade. You can also inject Puntjes\Generated\PuntjesClient from the container instead. Coming from 1.x? The package's UPGRADING.md lists what to change. See the package README for configuration options, custom HTTP clients, and testing guidance.


WordPress / WooCommerce

Build on puntjes/php-sdk directly. It deliberately has no hard dependency on Guzzle or any framework, which keeps it safe to bundle inside a plugin. Two things matter when distributing:

  1. Scope your dependencies with PHP-Scoper or Strauss, so your vendored libraries cannot collide with other plugins in the shared WordPress process.
  2. Cache tokens in a transient so every page load does not perform a token grant. The SDK README contains a copy-paste TransientTokenStore implementation and the full guidance.

.NET

dotnet add package Puntjes.Sdk
using Puntjes.Sdk;
using Puntjes.Sdk.Generated.Models;

var puntjes = PuntjesClientFactory.Create(
    Environment.GetEnvironmentVariable("PUNTJES_CLIENT_ID")!,
    Environment.GetEnvironmentVariable("PUNTJES_CLIENT_SECRET")!);

// A customer scans their card at the till.
var customer = (await puntjes.Customers.Lookup.GetAsync(c => c.QueryParameters.Identifier = scannedCard))?.Data;

// Record the purchase. Amounts are in cents.
var transaction = (await puntjes.Transactions.PostAsync(new CreateTransactionData
{
    Identifier = scannedCard,
    TotalAmount = 4200,
    IdempotencyKey = $"order-{orderId}",
}))?.Data;

Like the PHP SDK, the client is generated from the OpenAPI document and follows the URL: puntjes.Customers["42"].Wallet.GetAsync() is GET /customers/42/wallet. It handles the token, retries and errors the same way as the PHP SDK below. An error answer becomes a PuntjesException subclass, such as NotFoundException or ValidationException, with ErrorCode, RawCode and RequestId. Paginator.AllAsync() walks the pages of a list, and WalletPass.AppleAsync() returns the Apple pass as bytes. The README has every option.

Puntjes.Sdk is on nuget.org and follows SemVer. When the API gets a new minor or patch version, a new package version follows by itself.


Java

Add the JitPack repository and the dependency. The SDK needs Java 17 or newer.

<repositories>
  <repository>
    <id>jitpack.io</id>
    <url>https://jitpack.io</url>
  </repository>
</repositories>

<dependency>
  <groupId>com.github.PuntjesApp</groupId>
  <artifactId>puntjes-java</artifactId>
  <version>1.0.0</version>
</dependency>

For Gradle, add maven { url 'https://jitpack.io' } to repositories and implementation 'com.github.PuntjesApp:puntjes-java:1.0.0' to dependencies.

import app.puntjes.sdk.IdempotencyKey;
import app.puntjes.sdk.PuntjesClientFactory;
import app.puntjes.sdk.generated.models.CreateTransactionData;

var puntjes = PuntjesClientFactory.create(
        System.getenv("PUNTJES_CLIENT_ID"), System.getenv("PUNTJES_CLIENT_SECRET"));

// A customer scans their card at the till.
var customer = puntjes.customers().lookup().get(c -> c.queryParameters.identifier = scannedCard).getData();

// Record the purchase. Amounts are in cents.
var purchase = new CreateTransactionData();
purchase.setIdentifier(scannedCard);
purchase.setTotalAmount(4200);
purchase.setIdempotencyKey(IdempotencyKey.newKey());
var transaction = puntjes.transactions().post(purchase).getData();

The client is generated from the OpenAPI document, so a call follows the URL: puntjes.customers().byCustomer("42").wallet().get() is GET /customers/42/wallet. Tokens and retries work as in the PHP SDK above. IdempotencyKey.newKey() makes a key for a POST.

Every error is an unchecked PuntjesException. An error answer becomes a subclass, such as NotFoundException, ValidationException or RateLimitException. errorCode() gives the error code as an Optional, and requestId() gives the request id. ValidationException adds details(), the messages per field, and errorsFor("reward_id"), the messages of one field. A 422 for a business rule, such as INSUFFICIENT_BALANCE, is a plain PuntjesException.

Paginator.all() walks the pages of a list as a lazy Stream, and WalletPass.apple() returns the Apple pass as bytes. The README has every option.

JitPack serves com.github.PuntjesApp:puntjes-java, and it follows SemVer. It is not on Maven Central.


TypeScript and Node.js

npm install @puntjes/sdk
import { createPuntjes } from '@puntjes/sdk';

const puntjes = createPuntjes({
    clientId: process.env.PUNTJES_CLIENT_ID!,
    clientSecret: process.env.PUNTJES_CLIENT_SECRET!,
});

// A customer scans their card at the till.
const { data: customer } = await puntjes.lookupCustomer({ query: { identifier: scannedCard } });

// Record the purchase. Amounts are in cents.
const { data: transaction } = await puntjes.submitTransaction({
    body: { identifier: scannedCard, total_amount: 4200, idempotency_key: `order-${orderId}` },
});

The client is generated from the OpenAPI document, so every endpoint is a method with typed path, query and body. A call returns { data, request, response }, and the API's own data field sits inside it, so the record is customer.data. Tokens, retries and errors work as in the PHP SDK above. An error answer is thrown as a PuntjesError subclass, such as NotFoundError or ValidationError, with code, rawCode and requestId; RateLimitError has retryAfter, in milliseconds. paginate() walks the pages of a list, and walletPass.apple() returns the Apple pass as bytes. The README has every option.

The package is an ES module for Node 20 or later. Use it on a server only: the client secret gives full access to your shop's API and must never reach a browser. @puntjes/sdk is on npm and follows SemVer. Every version after 1.0.0 is published by our CI with provenance. When the API gets a new minor or patch version, a new package version follows by itself.


Delphi (experimental)

Download the two units from the latest release, or install them with Boss:

boss install github.com/PuntjesApp/puntjes-delphi
http := TJsonClient.Create('https://puntjes.app', 'api/v1');
puntjes := TPuntjesClient.Create(http);
http.SetBearer(accessToken);

writeln(puntjes.LookupCustomer(card).Data.WalletBalance);

The units are generated by mORMot 2 mopenapi from the OpenAPI document, and need mORMot 2 at tag 2.4-stable on your search path. They are experimental: CI compiles them with Free Pascal, but nobody has compiled them with Delphi's own compiler yet. A nullable field or a union arrives as a variant. An update sends every field: one you did not set goes out as null, and the API clears it, so copy every field you want to keep into the payload. An answer type such as TDtoPuntjes53 can get a new number in any release, so read .Data in the expression rather than declaring that type. Unlike the SDKs above, the units do not fetch or renew the token, retry, or page through lists: your code does that. The README has a complete program, with the token request.


What the SDK handles for you

  • Token lifecycle: fetch, cache, and re-fetch of client_credentials tokens, with a pluggable token store per platform.
  • Safe retries: a GET, PUT, PATCH or DELETE is retried with exponential backoff after a lost connection, a 5xx or a 429 RATE_LIMITED. A POST is retried only when its body carries an idempotency_key, because the API answers a repeated key with the first answer. The SDK does not make a key for you: set one on every POST that moves points, with IdempotencyKey::new() or a value from your own data such as 'order-'.$orderId. A POST without a key is never retried.
  • Typed errors: every error code maps to a named exception with the machine-readable code, HTTP status, and request_id attached. Rate limits expose retryAfter().
  • Pagination: Paginator::of() walks the pages of a list and asks for the next page only when your loop needs it:
use Puntjes\Generated\Products\ProductsRequestBuilderGetRequestConfiguration as Products;
use Puntjes\Pagination\Paginator;

$all = Paginator::of(fn (int $page) => $puntjes->products()->get(new Products(
    queryParameters: Products::createQueryParameters(page: $page),
))->wait());

foreach ($all as $product) {
    echo $product->getExternalId();
}

The OpenAPI document

PuntjesApp/puntjes-openapi holds the OpenAPI document of every released version of the API. A version appears there when Puntjes releases it. Each version has a tag, such as v1.2.0, and an entry in the repository's CHANGELOG.md.

The changeThe version
Breaks an existing client: a removed endpoint, a removed field every response carries, a new required inputMajor (2.0.0)
Any other change to the API: a new endpoint, a field, a deprecationMinor (1.3.0)
Only a description or an example changesPatch (1.2.1)

To generate your own client, read api.json at a tag rather than at main. Your client then changes only when you move to a newer tag.


Other languages

PHP, .NET, Java and TypeScript have an official SDK today, and Delphi has experimental units. From any other language, integrate against the REST API directly, or generate a client from the OpenAPI document. Every endpoint is plain JSON over OAuth2, and the reference pages in this documentation describe every request and response shape.