Skip to content

Cards ​

A card instance is the person's record. There is no separate user entity in TesserApp: id is our id for the card, external_ref is your id for the holder, and everything else hangs off the card.

That one sentence answers most of the questions this page exists for, so it is worth stating what follows from it before the tables.

external_ref is the join key. An email address is not. ​

Set external_ref when you issue the card and you can find that person again from your own system with one call:

http
GET /api/public/v1/cards?external_ref=EMP-00417 HTTP/1.1
Authorization: Bearer tsk_live_dP4y…

It comes back on every read and in the card.external_ref field of every webhook, and it is matched exactly — EMP-004 does not match EMP-00417, deliberately, because a partial match on an employee number is a privacy leak waiting for a support ticket.

A holder's email address is not an identity. One person can hold several cards of the same shop on the same program: a replacement badge issued while they are still carrying the old one, a second card after the first one expired, a spare for a shared role. Nothing in the system enforces one card per address, and that is a product decision rather than an oversight.

So ?holder_email= answers with a page of cards, not a card:

json
{
  "data": [
    { "id": "cin_01J9…", "holder_email": "anna@example.com", "status": "expired" },
    { "id": "cin_01JA…", "holder_email": "anna@example.com", "status": "locked" }
  ],
  "next_cursor": null
}

An integration that treats the first element as "Anna's card" will show the wrong one the first time somebody gets a replacement badge. Prefer card.external_ref; fall back to card.holder.email only when you have nothing else, and handle more than one result when you do.

Status, and the precedence that resolves it ​

A card has 6 possible status values and it is in exactly one of them. status is DERIVED — there is no status column — so it is resolved by precedence, and the precedence is what decides the answer when two conditions are true at once. Highest rank first:

#statusWhat it meansCan it be undone?
1archivedTaken out of service and kept for the record — "delete but keep the reference". Archiving also unbinds the holder’s device and clears the install, so the history stays and the card does not.No. Terminal: there is no unarchive anywhere in the system. Issue a new card.
2disabledSwitched off by the merchant. Every scan is refused with card_suspended and the holder’s wallet pass shows it as disabled — but the stamps, the balance, the expiry and the install on the holder’s phone are all untouched.Yes. POST /cards/{id}/enable returns the card to exactly the state it was in. This is the one to reach for when a badge should stop working for now.
3expiredThe card’s own expires_at has passed.Yes — PATCH /cards/{id} with a later expires_at, or reissue the card.
4lockedA personal card the holder has INSTALLED. installed_at is set, and the holder fields and the expiry are frozen from then on — a write to them returns card_locked.Not a state to leave: it is the normal steady state of a card in use.
4invitedA personal card that has been issued but not yet installed. The install link is single-use and expires.POST /cards/{id}/reissue mints a fresh install link for the SAME card.
5activeAn anonymous card (standard or points) in normal use.Not a state to leave.

Two consequences of the order, both of which have caught people out:

  • a card that is disabled and also past its expiry reads as disabled, not expired — deliberately, because enabling it is the action that works;
  • a card that was archived before it expired reads as archived forever, because archive is terminal.

Filter on it with ?status= on GET /cards. The filter runs in the database, so paging covers the right row set, and a value outside the 6 above is a 400 rather than an empty page.

The lifecycle writes ​

Generated from the server’s own operation registry.

EndpointWhat it doesScope
POST /programs/{id}/cardsIssue a cardcards:write
POST /cards/{id}/reissueReissue a cardcards:write
POST /cards/{id}/disableDisable a cardcards:write
POST /cards/{id}/enableEnable a previously disabled cardcards:write
POST /cards/{id}/archiveArchive a cardcards:write
DELETE /cards/{id}Delete a card (the GDPR / offboarding path)cards:write

The distinction that matters is disable vs archive vs delete, and it is not a matter of degree:

  • disable is the reversible switch. Nothing is lost and enable puts the card back exactly as it was.
  • archive is terminal. It unbinds the holder’s device and clears the install, and keeps the record for audit. There is no unarchive.
  • delete is GDPR erasure. The holder’s personal data goes, including the holder details stored in past webhook delivery records.

ARCHIVED is the state that answers 409 conflict, and it does so on FOUR endpoints, not two: an archived card can be neither disabled nor enabled, cannot be reissued (there is nothing left to install), and its holder fields can no longer be PATCHed — archive keeps them as the audit record of whose card it was. Enabling a card that is not disabled is the one other 409 here, a conflict rather than a no-op.

Which one you want ​

Reach for disable whenever the answer to "will this person need this card again?" is maybe. A suspended employee, a member who has stopped paying, a badge reported missing but not yet confirmed lost — all of those are disables. The card keeps its stamps, its balance and its expiry, the holder keeps the pass on their phone, and one call puts it back.

Reach for archive when the card is finished but the record is not. The holder's device is unbound and the install is cleared, the history stays, and there is no way back — so a badge you might reinstate is the wrong candidate.

Reach for delete only for erasure. It is the GDPR path: the holder's personal data goes, including the holder details stored in past webhook delivery records.

A disabled card refuses everything at the till. Every scan, redemption, gift card (type prepaid) deduction and reversal returns card_suspended — not because a check was added to each of them, but because they all gate on the card's state, which is why switching a card off cannot half-work.

Watching it happen ​

card.disabled and card.enabled are both in the webhook catalogue, and both carry the card block, so a CRM can follow a merchant switching a badge off in the dashboard without polling. The event's occurred_at is when it happened; the API surface itself reports only the resulting state, because the state is the authority on whether the card works.

What a card's barcode contains ​

A card barcode carries one of two payloads. Both are accepted everywhere a barcode payload is accepted, permanently, and GET /cards/{id} returns both so you never have to build either one yourself.

FieldPayloadUse it for
barcode.scan_urlhttps://tesserapp.eu/scan/prg_docsfixture0001/cin_docsfixture0001Anything new — a printed badge, a card in a portal. A phone’s own camera app offers to open it, which opens the merchant’s TesserApp Shop app straight onto the scan.
barcode.legacy_payloadtlp:prg_docsfixture0001:cin_docsfixture0001Matching existing badge stock. It is the original format, it is baked into every wallet pass already on a holder’s phone, and it will never be retired.

The path is /scan/{program_id}/{card_instance_id} and the two ids are the same opaque ids the API returns as program_id and id. Treat them as opaque: do not parse a prefix, do not assume a length, and do not decide whether a card exists by looking at one — the server is the only authority on that, and GET /cards/{id} is the way to ask.

Two things not to do with this:

  • Do not print install_qr_svg. The QR on the issue and reissue responses is the single-use, expiring INSTALL token that enrolls a phone. It is not the card, and it stops working once used.
  • Do not build the URL against your own domain. The scan payload is only recognised on TesserApp’s own host; /scan/a/b anywhere else is refused by the parser rather than resolved.

Printing a badge ​

GET /cards/{id} gives you everything a badge needs: the holder's name, the program's name, and barcode. Render the payload as a QR code at whatever size your stock is — the dashboard prints at real badge size from the same values.

Print barcode.scan_url unless you are matching existing stock. It is a URL, so it is the one that works with the camera a merchant already has open, and both formats resolve to the same card.

Reading one card ​

GET /cards/{id} also returns the locations the card works at. Those come from the program's scope, not the card's: a card carries no location anywhere in TesserApp, programs belong to the whole shop, and there is no definition of "this card belongs to that site". This is also why a location-restricted key is refused on the card endpoints with location_scope_unsupported instead of being served shop-wide cards — a restricted key cannot be told which cards are "its".

Transactions are the read that is site-attributed. To answer "what happened at Via Roma", use GET /transactions?location_id= — its own reference page is in the sidebar under /transactions.

Editing a card, and when you cannot ​

installed_at is the field to check before you offer an edit. Once it is set the pass is on a device: the holder fields and the expiry are frozen, and a write to them returns card_locked. That is a state and not a failure — a locked card is a card somebody is carrying, with the values already baked into the pass in their wallet.

When a holder needs a new device, reissue rather than delete and recreate. POST /cards/{id}/reissue clears the install lock so the same card rebinds on the next install, which means the stamps and the balance survive the new phone.

An archived card is the one case where neither of those works, and the distinction matters because the two 409s look alike: card_locked means "the pass is on a phone, so reissue it and the fields open again", while conflict on an archived card means "there is no way back". Archiving unbound the card permanently, so there is nothing left to install (POST /cards/{id}/reissue is a 409 conflict), and the holder name and email are kept deliberately, as the record of whose card it was — so they can no longer be PATCHed either. Issue a new card. external_ref stays yours to write in both cases.

Requires the Business API add-on.