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
| Package | For | Requires |
|---|---|---|
puntjes/php-sdk | Any PHP application: WooCommerce/WordPress plugins, POS bridges, cron jobs | PHP 8.1+ |
puntjes/laravel | Laravel applications | PHP 8.2+, Laravel 12–13 |
Puntjes.Sdk | Any .NET application: POS software, web shops, background services | .NET 8 or .NET 10 |
com.github.PuntjesApp:puntjes-java | Java applications: POS software, web shop back ends, background services | Java 17+ |
@puntjes/sdk | Node.js servers: web shop back ends, serverless functions, POS bridges | Node 20+ |
puntjes-delphi | Delphi and Free Pascal programs, experimental | mORMot 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:
- Scope your dependencies with PHP-Scoper or Strauss, so your vendored libraries cannot collide with other plugins in the shared WordPress process.
- Cache tokens in a transient so every page load does not perform a token grant. The SDK README contains a copy-paste
TransientTokenStoreimplementation 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_credentialstokens, with a pluggable token store per platform. - Safe retries: a
GET,PUT,PATCHorDELETEis retried with exponential backoff after a lost connection, a 5xx or a429 RATE_LIMITED. APOSTis retried only when its body carries anidempotency_key, because the API answers a repeated key with the first answer. The SDK does not make a key for you: set one on everyPOSTthat moves points, withIdempotencyKey::new()or a value from your own data such as'order-'.$orderId. APOSTwithout a key is never retried. - Typed errors: every error code maps to a named exception with the machine-readable code, HTTP status, and
request_idattached. Rate limits exposeretryAfter(). - 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 change | The version |
|---|---|
| Breaks an existing client: a removed endpoint, a removed field every response carries, a new required input | Major (2.0.0) |
| Any other change to the API: a new endpoint, a field, a deprecation | Minor (1.3.0) |
| Only a description or an example changes | Patch (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.