PHPackages                             puntjes/php-sdk - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [HTTP &amp; Networking](/categories/http)
4. /
5. puntjes/php-sdk

ActiveLibrary[HTTP &amp; Networking](/categories/http)

puntjes/php-sdk
===============

Framework-agnostic PHP client for the Puntjes loyalty API.

v0.1.1(today)02↑2900%MITPHPPHP ^8.1

Since Jul 30Pushed todayCompare

[ Source](https://github.com/TheGangOfFour/puntjes-php-sdk)[ Packagist](https://packagist.org/packages/puntjes/php-sdk)[ Docs](https://github.com/TheGangOfFour/puntjes-php-sdk)[ RSS](/packages/puntjes-php-sdk/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (10)Versions (3)Used By (0)

puntjes/php-sdk
===============

[](#puntjesphp-sdk)

Framework-agnostic PHP client for the [Puntjes](https://puntjes.app) loyalty API.

Works anywhere PHP 8.1 runs — a Laravel webshop, a WooCommerce plugin, a POS bridge, a cron script. No HTTP client is forced on you.

- **Laravel** → install [`puntjes/laravel`](https://github.com/TheGangOfFour/puntjes-laravel) on top for config, a facade and cache-backed tokens.
- **WordPress / WooCommerce** → use this package directly, plus the [notes below](#wordpress--woocommerce).

Install
-------

[](#install)

```
composer require puntjes/php-sdk
```

The SDK talks PSR-18, so it uses whatever HTTP client your project already has (Guzzle, Symfony HttpClient, …). If you have none, add one:

```
composer require guzzlehttp/guzzle
```

Quick start
-----------

[](#quick-start)

```
use Puntjes\Puntjes;
use Puntjes\Request\SubmitTransaction;

$puntjes = Puntjes::make(
    clientId:     getenv('PUNTJES_CLIENT_ID'),
    clientSecret: getenv('PUNTJES_CLIENT_SECRET'),
    baseUrl:      'https://puntjes.app/api/v1',
);

// A customer scans their card at the till.
$customer = $puntjes->customers->lookup(identifier: $scannedCard);

echo $customer->fullName();      // "Jan Everaert"
echo $customer->walletBalance;   // 320  (lookup folds the balance in)

// Record the purchase. Amounts are in cents.
$transaction = $puntjes->transactions->submit(new SubmitTransaction(
    identifier:     $scannedCard,
    totalAmount:    4200,
    idempotencyKey: 'order-'.$order->id,
));

echo $transaction->pointsEarned; // 42
```

Credentials come from **Puntjes → Settings → API clients**. The secret is shown once.

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

### Base URL

[](#base-url)

Pass the base URL exactly as the [API docs](https://docs.puntjes.app) state it — `https://puntjes.app/api/v1`. The bare host works too; both are accepted and equivalent:

```
baseUrl: 'https://puntjes.app/api/v1'   // as documented
baseUrl: 'https://puntjes.app'          // same thing
```

Internally the SDK keeps only the host, because the two endpoints it talks to do not share a prefix: the API is under `/api/v1`, but the OAuth token endpoint is at `/oauth/token`, off the root.

Amounts are in cents
--------------------

[](#amounts-are-in-cents)

Every monetary value on this API — `totalAmount`, `unitPrice`, `priceCents`, `revenueCents` — is an integer number of cents. €42.00 is `4200`.

The single exception is `Campaign::$minTransactionAmount`, which the API serialises in euros. It is documented on the model.

Retries and idempotency
-----------------------

[](#retries-and-idempotency)

Retrying a request that already moved points is how integrations double-credit customers, so the SDK is deliberately conservative about what it replays.

CallAuto-retried?WhyAny `GET`✅Reads have no effect`PUT` / `PATCH` / `DELETE`✅All keyed by external id; they converge`POST /transactions`✅Carries an `idempotency_key``POST /redemptions`✅Carries an `idempotency_key``POST …/wallet/adjust`✅Carries an `idempotency_key``POST /customers`❌A replay would create a second customer`POST /products`, `/products/batch`, `/products/{sku}/reward`❌No replay protection`POST /redemptions/{code}/verify`❌A replay would answer `CODE_ALREADY_USED`Retries fire only on failures a later attempt could survive — connection errors, 5xx, and `429 RATE_LIMITED` — with exponential backoff, honouring `Retry-After`. `429 PLAN_LIMIT_EXCEEDED` is excluded: it shares the status code but is a billing stop that waiting cannot clear.

**Idempotency keys** are generated automatically when you do not supply one, and the same key is reused across the SDK's own retries. That protection ends with the object, though — if *your* code catches a failure and rebuilds the request, derive the key from something stable in your system:

```
new SubmitTransaction(
    identifier:     $card,
    totalAmount:    4200,
    idempotencyKey: 'order-'.$order->id,   // survives a process restart
);
```

Keys are scoped per vendor. Reusing one for a different customer or reward returns `IDEMPOTENCY_KEY_CONFLICT` rather than someone else's confirmation code.

Errors
------

[](#errors)

Everything the SDK raises extends `Puntjes\Exception\PuntjesException`.

```
use Puntjes\Enum\ErrorCode;
use Puntjes\Exception\{ApiException, NotFoundException, ValidationException};

try {
    $puntjes->redemptions->create($redemption);
} catch (NotFoundException $e) {
    // 404 — unknown customer or reward
} catch (ValidationException $e) {
    $e->errorsFor('reward_id');   // ['The reward id field is required.']
} catch (ApiException $e) {
    if ($e->is(ErrorCode::InsufficientBalance)) { … }

    $e->status();      // 422
    $e->code();        // 'INSUFFICIENT_BALANCE'
    $e->requestId();   // quote this in a support ticket
}
```

ExceptionWhen`AuthenticationException`401 — bad, revoked or unlinked credentials`ForbiddenException`403 — vendor pending, suspended or deactivated`NotFoundException`404 — no such record *for this vendor*`ConflictException`409 — duplicate identifier, external id or SKU`ValidationException`422 `VALIDATION_ERROR`, with `errors()``RateLimitException`429 `RATE_LIMITED`, with `retryAfter()``PlanLimitExceededException`429 `PLAN_LIMIT_EXCEEDED` — upgrade the plan`ServerException`5xx`ApiException`Any other API error, including 422 domain refusals`TransportException`No HTTP response at all, or a non-JSON body`ConfigurationException`Bad settings — raised before any request> `422` covers both validation failures *and* domain refusals like `INSUFFICIENT_BALANCE` or `OUT_OF_STOCK`. Only the former is a `ValidationException`, so catching it never silently swallows a business outcome you needed to handle.

`ErrorCode` is an enum of every documented code. Unknown codes — a newer API than your SDK — leave `errorCode()` null while `code()` still returns the raw string, so a new server-side code can never break an older client.

Pagination
----------

[](#pagination)

List endpoints return a lazy `Paginator`. Iterating it walks every page on demand.

```
foreach ($puntjes->products->list() as $product) {   // fetches pages as needed
    echo $product->externalId;
}

$page = $puntjes->products->list()->firstPage();     // just the first page
$page->meta->total;
$page->hasMorePages();

$all = $puntjes->campaigns->list()->all();           // everything, eagerly
```

`rewards->list()` is not paginated — the catalogue comes back in one call.

Endpoints
---------

[](#endpoints)

```
// Customers
$puntjes->customers->lookup(identifier: 'CARD-1');       // or externalId:
$puntjes->customers->findByIdentifier('CARD-1');         // null instead of throwing
$puntjes->customers->findByExternalId('PNU-1');
$puntjes->customers->register(new CreateCustomer(...));
$puntjes->customers->find(42);
$puntjes->customers->updateByExternalId('PNU-1', new UpdateCustomer(email: 'new@example.com'));

// Transactions
$puntjes->transactions->submit(new SubmitTransaction(...));
$puntjes->transactions->forCustomer(42, new DateRangeFilters(dateFrom: '2026-07-01'));

// Wallets
$puntjes->wallets->show(42);
$puntjes->wallets->ledger(42, new DateRangeFilters(type: 'earn'));
$puntjes->wallets->adjust(42, AdjustWallet::credit(100, 'Goodwill'));
$puntjes->wallets->applePass(42);        // raw .pkpass bytes — EXPERIMENTAL, see below
$puntjes->wallets->googlePassUrl(42);    // save URL to redirect to — EXPERIMENTAL, see below

// Rewards & redemptions
$puntjes->rewards->list();
$puntjes->rewards->list(affordableFor: 'CARD-1');
$puntjes->redemptions->create(new CreateRedemption('CARD-1', rewardId: 3));
$puntjes->redemptions->find('PNTJ-ABC123');
$puntjes->redemptions->verify('PNTJ-ABC123');

// Products — keyed on YOUR SKU, never on a Puntjes id
$puntjes->products->list(new ProductFilters(category: 'Bakery'));
$puntjes->products->create(new CreateProduct(externalId: 'SKU-1', name: 'Brood'));
$puntjes->products->find('SKU-1');
$puntjes->products->findOrNull('SKU-1');
$puntjes->products->upsert('SKU-1', new UpsertProduct(name: 'Brood'));   // sync primitive
$puntjes->products->update('SKU-1', new UpdateProduct(priceCents: 275)); // partial
$puntjes->products->delete('SKU-1');
$puntjes->products->batchUpsert(['SKU-1' => $a, 'SKU-2' => $b]);         // max 100
$puntjes->products->createReward('SKU-1', new CreateRewardFromProduct(pointCost: 200));

// Campaigns, statistics, vendor
$puntjes->campaigns->list();
$puntjes->statistics->get(Period::ThirtyDays);
$puntjes->me();
$puntjes->ping();   // unauthenticated health check
```

### Wallet passes (experimental)

[](#wallet-passes-experimental)

`applePass()` and `googlePassUrl()` target `GET /customers/{id}/wallet-pass`, a feature that is **not yet fully integrated on the Puntjes side**. Treat both as experimental: verify them against your target environment before shipping, and give your HTTP client a request timeout — an instance whose pass integration is incomplete can hang rather than fail. `googlePassUrl()` throws a `TransportException` when the response carries no usable save URL, so you can never end up redirecting a customer to an empty string.

### Catalogue sync

[](#catalogue-sync)

`upsert()` is the primitive to build on: pushing the same row repeatedly converges on one product, so a sync job is safe to re-run.

```
$result = $puntjes->products->batchUpsert($chunk);   // up to 100 at a time
```

`batchUpsert` **never throws for a bad row** — each item is processed independently and the call answers 200 even when every item failed. Always inspect the result:

```
if ($result->hasFailures()) {
    foreach ($result->failures() as $failure) {
        $log->warning('Product rejected', [
            'sku' => $failure->externalId,
            'errors' => $failure->errors,
        ]);
    }
}
```

Note that `upsert()` (PUT) **replaces** the whole product — omitted nullable fields are cleared. That is what makes it converge. Use `update()` (PATCH) to change one field and leave the rest alone.

### Omitted vs null on partial updates

[](#omitted-vs-null-on-partial-updates)

PATCH requests distinguish "leave this alone" from "clear this", so the defaults are `Undefined`, not `null`:

```
new UpdateCustomer(email: 'new@example.com')  // changes only the email
new UpdateCustomer(phone: null)               // clears the phone number
```

Token storage
-------------

[](#token-storage)

By default tokens live in memory for one PHP process, which under PHP-FPM means one token grant per web request. Correct, but wasteful — supply a shared store:

```
$puntjes = Puntjes::make(..., tokenStore: $yourStore);
```

`TokenStore` has three methods (`get`, `put`, `forget`). Laravel users get a cache-backed implementation from `puntjes/laravel`; a WordPress one is below.

Bringing your own HTTP client
-----------------------------

[](#bringing-your-own-http-client)

Timeouts, proxies, TLS and instrumentation belong to the HTTP client, not to this SDK:

```
$puntjes = Puntjes::make(
    ...,
    httpClient: new GuzzleHttp\Client([
        'timeout' => 10,
        'connect_timeout' => 5,
        'http_errors' => false,   // let the SDK map error responses
    ]),
);
```

WordPress / WooCommerce
-----------------------

[](#wordpress--woocommerce)

Two things matter when shipping this inside a plugin.

**1. Scope your dependencies.** A WordPress site runs every plugin in one PHP process. If two plugins bundle different versions of the same library, one of them breaks. Run [PHP-Scoper](https://github.com/humbug/php-scoper) or [Strauss](https://github.com/BrianHenryIE/strauss) over your vendor directory before distributing (Mozart is abandoned; Strauss is its successor). This SDK keeps its dependency list to PSR interfaces plus discovery precisely to make that cheap.

**2. Cache tokens in a transient.** Otherwise every page load grants a new token:

```
use Puntjes\Auth\{AccessToken, TokenStore};

final class TransientTokenStore implements TokenStore
{
    public function get(string $key): ?AccessToken
    {
        return AccessToken::fromArray(get_transient($this->key($key)) ?: null);
    }

    public function put(string $key, AccessToken $token, int $ttl): void
    {
        set_transient($this->key($key), $token->toArray(), max(1, $ttl));
    }

    public function forget(string $key): void
    {
        delete_transient($this->key($key));
    }

    private function key(string $key): string
    {
        // Transient keys are capped at 172 characters.
        return 'puntjes_'.md5($key);
    }
}
```

Store credentials in `wp_options`, encrypted at rest where the host allows it, and never log the secret.

Rate limits
-----------

[](#rate-limits)

Limits are per OAuth client (not per IP) and derived from the vendor's plan — 60 requests/minute on the free tier. Several servers sharing one client share one budget. Exceeding it raises `RateLimitException`; the SDK already backs off and retries within `maxRetries`.

Secret rotation, without downtime
---------------------------------

[](#secret-rotation-without-downtime)

A vendor may hold up to 10 active API clients, so rotation needs no maintenance window:

1. Create a new API client in the portal.
2. Deploy the new credentials.
3. Revoke the old client.

Tokens are cached per credential pair, so the two never collide mid-rotation.

Escape hatch
------------

[](#escape-hatch)

For an endpoint this SDK does not model yet, or a field added after this version shipped — authentication, retries and error mapping still apply:

```
$response = $puntjes->request('GET', '/some/new/endpoint', ['page' => 2]);
$response->dataArray();
```

Development
-----------

[](#development)

```
composer install
composer test        # unit tests, no network
composer analyse     # PHPStan level 6
composer format      # Pint
```

### Contract tests

[](#contract-tests)

The unit suite proves the SDK behaves correctly against fixtures the SDK's own author wrote — which is exactly the blind spot that lets a wrong assumption about the wire format ship. The contract suite closes it by running against a real instance:

```
PUNTJES_BASE_URL=http://localhost \
PUNTJES_CLIENT_ID=… \
PUNTJES_CLIENT_SECRET=… \
vendor/bin/phpunit --testsuite Contract
```

It is skipped when those variables are unset. Everything it writes is a product with a run-unique SKU, deleted afterwards whether or not the tests passed. It creates no customer, transaction or points data — the API cannot delete those, and a test has no business leaving balances behind.

Links
-----

[](#links)

- API documentation —
- OpenAPI spec — `{your Puntjes host}/docs/api.json`

License
-------

[](#license)

MIT.

###  Health Score

37

—

LowBetter than 81% of packages

Maintenance100

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community6

Small or concentrated contributor base

Maturity33

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

0d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/15e6cbb2bcf0d3a7b424f0442acf726db53a822af83b63eb4626f2cfbc707517?d=identicon)[JuniorE.](/maintainers/JuniorE.)

---

Top Contributors

[![JuniorE](https://avatars.githubusercontent.com/u/4274000?v=4)](https://github.com/JuniorE "JuniorE (4 commits)")

---

Tags

apisdkpsr-18loyaltypuntjes

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StyleLaravel Pint

Type Coverage Yes

### Embed Badge

![Health badge](/badges/puntjes-php-sdk/health.svg)

```
[![Health](https://phpackages.com/badges/puntjes-php-sdk/health.svg)](https://phpackages.com/packages/puntjes-php-sdk)
```

###  Alternatives

[telnyx/telnyx-php

Official Telnyx PHP SDK — APIs for Voice, SMS, MMS, WhatsApp, Fax, SIP Trunking, Wireless IoT, Call Control, and more. Build global communications on Telnyx's private carrier-grade network.

36789.4k2](/packages/telnyx-telnyx-php)[flow-php/flow

PHP ETL - Extract Transform Load - Data processing framework

85036.3k](/packages/flow-php-flow)[tempest/framework

The PHP framework that gets out of your way.

2.2k34.4k17](/packages/tempest-framework)[openai-php/client

OpenAI PHP is a supercharged PHP API client that allows you to interact with the Open AI API

5.8k28.0M327](/packages/openai-php-client)[getbrevo/brevo-php

Official Brevo provided RESTFul API V3 php library

1003.9M50](/packages/getbrevo-brevo-php)[laudis/neo4j-php-client

Neo4j-PHP-Client is the most advanced PHP Client for Neo4j

185702.8k44](/packages/laudis-neo4j-php-client)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
