Skip to content

The first fifteen minutes

Requires the Business API add-on

The Business API is available only with the Business API add-on, switched on from Billing in your dashboard. It is an add-on, not a different plan: your locations bill exactly as they do now. What it includes.

End to end: a key, a program, a card carrying your id for the person, a webhook, and a real event arriving when that card is used.

Status

The Business API is implemented and ships with the integration module; this walkthrough describes it as built. Webhook delivery, signing and the delivery log are live today, and every step below can also be done in the dashboard.

Everything below uses curl and one environment variable.

bash
export TESSERAPP_API_KEY='tsk_live_…'
export API='https://api.tesserapp.eu/api/public/v1'

1. Create a key

In the dashboard: Settings → API → Create key.

  • Name it after the system that will hold it. "Odoo nightly sync", not "test".
  • Scopes for this walkthrough: programs:read, programs:write, cards:read, cards:write, transactions:read, webhooks:read, webhooks:write.
  • Leave the location restriction empty (all locations).
  • Copy the key now. It is shown once and cannot be re-read.

2. Confirm who you are

bash
curl -s "$API/me" -H "Authorization: Bearer $TESSERAPP_API_KEY" | jq
json
{
  "shop": { "id": "shp_01J9…", "name": "Caffè Centrale" },
  "plan": "api_only",
  "entitlements": { "api_access": true, "webhooks_advanced": true, "mcp_access": false },
  "locations": { "included": 5, "used": 2 },
  "key": { "id": "key_01J9…", "scopes": ["cards:write", "…"], "location_ids": null }
}

The numbers in locations are illustrative — the included count for your shop is a plan setting, listed in Plan limits.

key.scopes and key.location_ids are the calling key's own. If anything later returns 403, come back to this call first — it answers "why" faster than reading the reference.

3. Create a program

A program is a card design plus its rules. A card is one person's instance of it.

bash
curl -s -X POST "$API/programs" \
  -H "Authorization: Bearer $TESSERAPP_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: quickstart-program-1' \
  -d '{ "name": "Staff Access", "type": "access" }' | jq

Note the Idempotency-Key: re-run that command and you get the same program back, not a second one. Every write accepts it — see Idempotency.

Keep the returned id (prg_…) for the next step.

4. Register a webhook — before you issue the card

Do this first, so the card's own events are the ones you watch arrive.

In the dashboard: Settings → Webhooks → Add webhook — or over the API, which does the same thing:

bash
curl -s -X POST "$API/programs/prg_01J9…/webhooks" \
  -H "Authorization: Bearer $TESSERAPP_API_KEY" \
  -H 'Content-Type: application/json' \
  -d '{
        "url": "https://example.com/tesserapp",
        "events": ["card.*"],
        "description": "Quickstart receiver"
      }' | jq
  • url must be https, and must resolve to a public address — private, loopback and metadata addresses are refused.
  • events: ["card.*"] subscribes to every current and future card. event, including ones we ship later.
  • Leaving the location filter out means all locations. Empty means all, not none — see the delivery rules, which are the three things worth reading before you debug a webhook.
  • You are shown the signing secret once. Store it now.

Then verify your receiver against a real signed delivery: Send test in the dashboard — or POST $API/programs/prg_01J9…/webhooks/whk_01J9…/test — posts a test.ping signed exactly like a real event. Use the verification snippet for your language — do not write the HMAC by hand, and read the raw body.

5. Issue a card with your own id

This is the call the whole integration exists for.

bash
curl -s -X POST "$API/programs/prg_01J9…/cards" \
  -H "Authorization: Bearer $TESSERAPP_API_KEY" \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: issue-card:EMP-00418' \
  -d '{
        "external_ref": "EMP-00418",
        "holder": { "name": "A. Rossi", "email": "a.rossi@example.com" }
      }' | jq

external_ref is your id for the person — the employee number, the CRM contact id. It comes back on every card read and in every webhook payload, and it is the field you join on. Setting it at issue time is the difference between an integration that reconciles and one that guesses. See which field is the user id.

The idempotency key derived from your own record (issue-card:EMP-00418) means a crashed-and-restarted job re-runs safely.

6. Use the card, and receive the event

Install the card on a phone (or use the enrollment QR) and present it on a shop app at a paired location. Within seconds:

json
{
  "event": "card.used",
  "event_id": "whe_01J9…",
  "payload_version": "v2",
  "occurred_at": "2026-09-04T10:15:00.000Z",
  "shop":     { "id": "shp_01J9…", "name": "Caffè Centrale" },
  "location": { "id": "loc_01J9…", "name": "Milano Centrale" },
  "program":  { "id": "prg_01J9…", "name": "Staff Access", "type": "access" },
  "card": {
    "id": "cin_01J9…",
    "external_ref": "EMP-00418",
    "state": "active",
    "holder": { "name": "A. Rossi", "email": "a.rossi@example.com" }
  },
  "transaction": { "id": "trx_01J9…", "type": "use", "metadata": null },
  "actor": { "type": "device", "id": "dev_01J9…" },
  "data": { "…": "the v1 shape for this event, verbatim" }
}

Your handler should:

  1. verify the signature over the raw bytes;
  2. claim event_id (at-least-once delivery means duplicates are normal);
  3. look up card.external_ref in your system — falling back to card.holder.email, and expecting either to be null;
  4. return 2xx quickly and do the rest asynchronously.

Nothing arrived? The delivery log in the dashboard shows every attempt with your status code, and the delivery rules cover the three reasons a webhook that looks right does not fire.

7. Read what happened

bash
curl -s "$API/transactions?limit=50" -H "Authorization: Bearer $TESSERAPP_API_KEY" | jq
curl -s "$API/cards?external_ref=EMP-00418" -H "Authorization: Bearer $TESSERAPP_API_KEY" | jq

Both are cursor-paginated: follow next_cursor until it is null (pagination). Use these to backfill; use webhooks to keep up.

What you cannot do, so you do not look for it

You cannot write a transaction over the API — no stamp, no redeem, no reverse. A transaction is the record that a real card was presented at a real place, and an API key that could mint one would make the whole log unfalsifiable. Cards are created by you; transactions are created by a scan. The full reasoning.

Next

  • Authentication — rotation, revocation, restricting a key to one site.
  • Integration kit — the verification and retry code, tested.
  • Errors — what to retry, what to escalate, what to fix.
  • Webhooks — the catalogue and the delivery rules.

Requires the Business API add-on.