Guides

Partner integrations

A partner is a company whose app (a till, a webshop) connects to Puntjes for many vendors. Without this flow, each vendor creates an API client in the portal and copies its secret into your app. With it, the vendor clicks Allow in Puntjes once, and your app collects an API client of that vendor by itself.

The flow is OAuth2 authorization code with PKCE, followed by one call to the partner API. After that call your app is an ordinary integration of the vendor and uses the client credentials grant, as described in Authentication.


What you get from Puntjes

Puntjes registers your app as a partner and gives you:

  • a partner client ID;
  • for a confidential client (a server that can keep a secret), a client secret, shown once. Store it at once: Puntjes keeps only a hash;
  • for a public client (an app on a device that cannot keep a secret), no secret at all.

You give Puntjes 1 to 10 redirect addresses: full https addresses with no * and no #. The redirect_uri you send later must be one of them, character for character. A public client can also give a loopback address, as A desktop app explains.

You can also give Puntjes a connect address: a full https address of the page on your site where a vendor starts the connection. The partner page in the Puntjes portal then has a Connect button that sends the vendor's admin there. Without a connect address, the button opens your website.

The partner client cannot call the vendor API. It only starts connections.


1. Send the vendor to Puntjes

Open this URL in the vendor's browser:

GET https://puntjes.app/oauth/authorize
    ?response_type=code
    &client_id=YOUR_PARTNER_CLIENT_ID
    &redirect_uri=https://app.example.com/puntjes/callback
    &state=YOUR_STATE
    &code_challenge=YOUR_CODE_CHALLENGE
    &code_challenge_method=S256
    &branch=kerkstraat
ParameterRequiredDescription
response_typeYesAlways code
client_idYesYour partner client ID
redirect_uriYesOne of your registered redirect addresses, exactly. At most 2000 characters
stateYesA random value you keep for this browser. Puntjes sends it back unchanged. At most 500 characters
code_challengeYesBASE64URL(SHA256(code_verifier)), with no = padding. At most 128 characters
code_challenge_methodYesAlways S256
branchNoThe external_id of one of the vendor's branches. Puntjes selects it on the consent screen. At most 64 characters

The code_verifier is a random string of 43 to 128 characters from A-Z, a-z, 0-9, -, ., _ and ~. Keep it with the state: you send it in step 3.

Send code_challenge_method=S256 every time

The OAuth server takes plain when code_challenge_method is missing, and Puntjes refuses plain. A request with no method or with plain returns to your redirect address with error=invalid_request, and so does a request with no code_challenge from a confidential client. A public client with no code_challenge, a code_challenge that is not 43 to 128 characters of A-Z, a-z, 0-9, -, ., _ and ~, or a method other than S256 and plain gets an error in the browser (400 invalid_request), with no redirect.

Do not send scope: Puntjes has no scopes, and a request with one returns with error=invalid_scope. Do not send prompt=none either: the vendor must see the screen, and Puntjes returns error=consent_required.

Puntjes does not trust a request with an unknown client ID, a redirect address that is not registered, or a parameter over its limit. It shows an error in the browser and does not redirect, because it cannot know that the address belongs to you.

The vendor logs in if needed and sees your app's name on the consent screen. They choose a default branch, or none, and click Allow or Deny. A branch you named only selects that branch in advance: the vendor can choose another one. A key that matches no active branch is ignored, and the first active branch is selected. Only a vendor admin who may manage the API clients can allow.


2. Read the redirect

After Allow, the browser comes back to your redirect_uri with three parameters:

https://app.example.com/puntjes/callback?code=…&state=YOUR_STATE&connection=…
ParameterDescription
codeThe authorization code for step 3
stateYour state, unchanged. Compare it with the value you kept, and stop if it differs
connectionThe handle of this connection, for step 4. It works for 60 minutes

A redirect address that already has a query string keeps it; Puntjes adds the three parameters after it.

After Deny, the browser comes back with error=access_denied and your state, and nothing is saved.

Keep this URL out of your logs

The redirect URL holds the code and the connection handle. Together with a token they give an API client of the vendor. Do not write the full URL to a log, an analytics tool or an error report.


3. Swap the code for a token

Send the code to the token endpoint soon: an authorization code works for 10 minutes and only once.

curl -X POST https://puntjes.app/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d grant_type=authorization_code \
  -d client_id=YOUR_PARTNER_CLIENT_ID \
  -d client_secret=YOUR_PARTNER_CLIENT_SECRET \
  -d redirect_uri=https://app.example.com/puntjes/callback \
  -d code=THE_CODE \
  -d code_verifier=YOUR_CODE_VERIFIER

redirect_uri is the same value as in step 1. A confidential client sends its client_secret; a public client leaves it out. The answer holds an access_token that works for 60 minutes. There is no refresh token: the token is for step 4 only, and the next connection starts at step 1 again.


4. Collect the API client

curl -X POST https://puntjes.app/api/v1/partner/clients \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"connection":"THE_CONNECTION_HANDLE","label":"Kassa 1"}'

label is the name the vendor sees for this client in the portal, at most 100 characters. The answer is 201 with the new client:

{
    "data": {
        "client_id": "9f3c2a6e-4b1d-4c8e-9a7f-2d5e8b1c0a43",
        "client_secret": "YOUR_NEW_CLIENT_SECRET",
        "vendor": { "id": 7, "name": "Bakkerij Peeters" },
        "branch": { "external_id": "kerkstraat", "name": "Kerkstraat", "type": "physical" }
    }
}

branch is the default branch the vendor chose, or null. Store client_secret at once.

A retry is safe. Send the same connection again within its 60 minutes and you get the same client_id and client_secret, also when two calls arrive at the same time. Puntjes makes one client per connection. After 60 minutes the handle answers CONNECTION_EXPIRED, and CONNECTION_NOT_FOUND once the nightly clean-up has deleted the connection. Either way, the secret is not sent again.

The Partner endpoints page has the full reference.

When the call fails

Some refusals only the vendor can fix. Show the vendor what to do, then retry with the same handle while it is valid.

CodeStatusWho can fix it
CLIENT_LIMIT_REACHED422The vendor: it revokes an API client in the portal. Then you retry with the same handle
BRANCH_INACTIVE422The vendor: it opens the chosen branch again, then you retry; or the vendor connects again
VENDOR_PENDING, VENDOR_SUSPENDED, VENDOR_DEACTIVATED, VENDOR_INACTIVE403The vendor, with Puntjes: the vendor's account cannot use the API
SUBSCRIPTION_EXPIRED403The vendor: it chooses a plan under Billing
CONNECTION_EXPIRED422Nobody: the handle is older than 60 minutes. The vendor connects again
CONNECTION_NOT_FOUND404Nobody: the vendor connects again
VALIDATION_ERROR422You: connection or label is missing or too long
UNAUTHENTICATED401You: the token is missing, expired, or your partner access was revoked
INVALID_CLIENT401You: the token is not a partner token from step 3, for example a client credentials token

CONNECTION_NOT_FOUND has one answer for several causes on purpose: an unknown handle, a handle of another partner, a handle allowed by another user than the token's user, a user who is no longer an admin of the vendor, a client the vendor revoked after you collected it, or an expired handle after the nightly clean-up. A wrong handle does not tell the caller which check failed.


A desktop app

A desktop app cannot keep a secret, and it cannot get a redirect on an https address. It uses a listener on the same computer instead (RFC 8252, section 7.3). Do this:

  • Let Puntjes register your app as a public client with a loopback redirect address: http://127.0.0.1/puntjes/callback or http://[::1]/puntjes/callback. Leave the port out. In step 1 you can then send any port, and the path must stay the same. In production Puntjes refuses http://localhost, because a name can point to another address.
  • Listen only on the loopback address you registered (127.0.0.1 or [::1]), never on all network addresses of the computer.
  • Open the system browser for step 1, not a web view inside your app (RFC 8252, section 8.12).
  • Accept one request on the listener. Answer it with a page at an address without the query, so the code leaves the address bar. Then stop the listener.
  • Send no client_secret in step 3.
  • Send the same redirect_uri in step 3 as in step 1, with the same port. Another port answers 400 invalid_request.
  • Keep the redirect URL out of your logs: it holds the code and the connection handle.

Limits

WhatLimit
POST /api/v1/partner/clients30 calls a minute per partner client; 30 a minute per address with no valid token
POST /api/v1/client/secret and DELETE /api/v1/client10 calls a minute per client for the two together; 10 a minute per address with no valid token
GET /oauth/authorize30 requests a minute per browser address, or per signed-in user
POST /oauth/token60 requests a minute per client ID from one IP address; 600 a minute per IP address
Authorization code10 minutes, one use
Access token from the code60 minutes, no refresh token
connection handle60 minutes

Over a limit, the partner API answers 429 RATE_LIMITED.


After the connection

The client you collected is a normal API client of the vendor. Get a token with the client credentials grant (see Authentication) and call the vendor API with it. The client can use the whole vendor: every branch, every customer. The branch from the connection is only the default for a request that names no branch.

The vendor sees the client in the portal under API clients, with your label, and can revoke it there.

Your app can act on the client without the vendor. When the secret may have leaked, it gets a new one with POST /api/v1/client/secret. When the app is reinstalled or goes out of use, it deletes the client with DELETE /api/v1/client, so old clients do not fill the vendor's limit of API clients. Both calls take a token of the client and its client_secret; Partner endpoints has the details.