Sandbox · nothing here is real money or a real pass

Put your passes on the Ta'ab Card

Issue a ticket, a membership or a monthly pass to anyone with a Ta'ab card or the Ta'ab app, and redeem it when they tap or show their QR. One API, server to server. No money moves through it.

Base URL https://sandbox.taab.mohbel.com/api/partner/v1

How Ta'ab works

Ta'ab is a wallet for passes. People carry a Ta'ab card or the Ta'ab app. Organisations put passes in their wallets and accept them at the door. Ta'ab keeps the record: who holds what, and every time something was used or refused.

Inside a single organisation Ta'ab also holds stored value, such as a school's cafeteria balance. Value does not yet move from one organisation to another.

The seven things

Account. One person's wallet. Each has a permanent Ta'ab ID, like TB-00000001234. The person's name and login live in MOHBEL's sign-in service, not in Ta'ab.

An account can exist before anyone signs in: a card sold at a bus company's counter to a rider with no phone, or one a school gives a student. The card and its passes work at once. The slip that comes with it carries a code; whoever enters it later takes the account over, or moves the card and its passes into the wallet they already have.

Connection. An account's link to an institution: a place on its roster (a school registers a student, and the student accepts the invite), a card bought at its counter, or a partner app the holder linked. One person can have several, so a student at a school can also be linked to an employer and to a bus company's app. Either side can end a connection; doing so never touches the wallet or passes. An institution can give everyone on its roster one of its passes in one go: an employer's staff bus pass, say.

Credential. What a reader reads. There are two kinds: the QR in the Ta'ab app, which changes every 60 seconds so a screenshot is useless, and a Ta'ab card, identified by the UID its chip reports. Ta'ab stores only a hash of each.

Institution. Any organisation on the network: a school, a bus company, a venue, an employer, a shop. An institution issues passes, accepts them, or both.

Product. A pass an institution offers from its catalog: "Monthly pass", "10-ride bundle". A product has a type, a length in days, and either a number of uses or none (unlimited while it lasts). Its days start counting when a pass is issued, or the first time the pass is used; a pass that waits for its first use lapses if it is not used within a year.

A product is honoured by the institution that owns it. It can also carry a list of other institutions that honour it: one pass, several bus companies. Only Ta'ab adds an institution to that list, with that institution's agreement.

Pass. One account's holding. It comes from a product, or it is a one-off issued under the issuer's own reference, such as a ticket for one trip. A pass has a validity window and either a count of uses left or none. Buying a product again while its pass is in use, the next month of a monthly pass, say, renews that pass: its window gets longer, and the holder never holds two.

Redemption. One presentation of a credential at an institution. Ta'ab decides, and records the decision whether it is an approval or a denial.

What happens on a tap

  1. A reader reads the card's UID or the app's QR and sends it to Ta'ab, along with what it wants: a kind of event (a bus ride, a meal) or a specific pass.
  2. Ta'ab finds the credential. If it matches nothing Ta'ab issued, the request fails and nothing is recorded, because there is no account to record it against.
  3. Ta'ab checks the presenter: is the credential current, is the account in good standing?
  4. Ta'ab finds the pass. A partner's backend can name the exact pass by its own reference. Otherwise the reader names a kind of event, and Ta'ab looks at every pass of that kind the holder has.
  5. Ta'ab checks each one: does it belong to the presenter, is it valid now, is it honoured at this institution, has it a use left?
  6. Ta'ab spends something from the best pass that passed: one use of a counted pass, or money from the wallet for a cafeteria purchase. A time-window pass spends nothing, so it is used before a counted one; between two alike, the one that lapses sooner goes first. An unlimited bus pass carries one rider: a second ride on it with the same operator within 10 minutes is refused, so one card can't board two people. A pass with a daily limit, such as a meal plan of 3 a day, is refused once that day's uses are spent.
  7. Ta'ab records the event and answers.

A holder can have a school bus pass and a city-wide pass side by side. Each is used where it is honoured, and neither gets in the other's way. When nothing can be used, the reason given is about the pass that belongs at that reader: "expired" for a lapsed pass that would have been honoured there, not "wrong institution" for a good pass from somewhere else.

Steps 3 to 6 can each end in a denial. A denial is an answer, not a failure: it is recorded in the holder's history with its reason.

Every caller sends a key that identifies the tap. If the same key arrives again, because of a retry or a reader uploading later, Ta'ab returns the original answer and spends nothing twice.

Three ways in

Who calls API Signs in with For
The Ta'ab app /api/v1 The person's own login Their balance, passes, history and QR
An organisation's backend /api/partner/v1 A client id and secret, limited by scopes Issuing its passes and redeeming them
A scan device /api/scan A device id and secret Redeeming at a reader

People also use two web portals: the wallet, for holders, and the admin portal, for an institution's staff. Staff can also redeem passes from a phone with the staff scanner (/scan): it reads the QR code in the Ta'ab app, taps a card where the phone has NFC, or takes a typed card number. Each scan records who made it.

There are two deployments. The live one is taab.mohbel.com. The sandbox, sandbox.taab.mohbel.com, runs the same code against its own database: signing up there is approved at once and comes with test accounts and cards, so a company can try everything before it applies to go live.

The two machine APIs never touch the login system. A device or a backend proves who it is with its own secret, which Ta'ab shows once and stores only as a hash. Each can be disabled on its own without affecting anything else.

Who decides what

Ta'ab answers two questions: who holds this credential, and what is the state of this pass.

Everything else belongs to the organisation: whether the ticket is for today's bus, which seat, what it cost. A pass carries display data for the app, such as a route and a departure time, and Ta'ab never interprets it.

Money today

Stored value is closed-loop. A cashier records a cash top-up; a purchase at that institution's till debits the wallet. Amounts are whole cents in Belize dollars. The ledger is append-only: a mistake is corrected by a new entry, never by changing an old one, and every entry records the balance after it.

No card payments, no online top-ups, and no value crossing from one institution to another. Those wait for a payment licence.

Where to go next

  • Issuing and redeeming passes from your own system: the partner API guide.
  • Building a reader or a till: the device API guide.
  • The exact fields of every endpoint: the reference, and the OpenAPI document at /api/partner/v1/openapi.json.

Partner API guide

How your backend uses Ta'ab as a pass and card provider: find out whose card or QR this is, put a pass in their wallet under your own reference, and redeem it when they tap their card or show the Ta'ab app.

This guide covers what holds across the whole API. Each endpoint's fields, examples and errors are in the reference below it on /developers, which is generated from the code, and in the OpenAPI document at /api/partner/v1/openapi.json.

Ta'ab answers two questions only: who holds this credential and what is the state of this pass. Whether the pass's trip is today, on this bus, in this seat is yours to decide. Ta'ab never sees it.

You can also accept passes you did not issue. If your institution is on the acceptance list of another institution's product, such as a T-Pass that several bus companies honour, its holders can use it with you. See "Passes you accept but did not issue".

No money moves through this API. Nothing here debits a wallet.

Getting access

Start in the sandbox, https://sandbox.taab.mohbel.com. It runs the same code against its own database, and nothing in it is real money or a real pass.

  1. Sign in and sign up your organisation at /institutions/request. In the sandbox this is approved at once, and you are its owner.
  2. Under API clients in the admin portal, create a client. Its id is made for you; its secret is shown once, and only its hash is stored. Rotating it replaces the old one at once; disabling it stops it at once.
  3. Under Test cards, your organisation has five test accounts. Each has a QR code, and three have a card, with its UID and printed number. Issue them passes through this API and redeem them by their externalRef.

You can also try Ta'ab without code: add a product to your catalog, issue it to a test account in the admin portal, and redeem it with the staff scanner at /scan. The scanner picks the holder's pass by type, so it never uses a pass you issued with an externalRef: a ticket means a seat on a date, which Ta'ab doesn't check.

To go live, apply at https://taab.mohbel.com/institutions/request. Ta'ab reviews the application; once approved, you are the owner there too and create your live clients the same way. Sandbox clients, passes and accounts stay in the sandbox.

Every client belongs to one institution, the organisation on the Ta'ab network that its passes are issued by. Only an institution's owners manage its clients.

Authentication

Every request carries your client credentials:

Authorization: Basic base64(clientId:secret)
Content-Type: application/json

This is server to server. Keep the secret on your backend, never in a mobile or browser app.

Each client is granted scopes, checked on every call:

Scope Allows
identity.resolve POST /identity/resolve
connections.link POST /links (with or without a credential), GET /links/{linkId}
cards.activate POST /cards/activate
passes.issue POST /passes, POST /passes/{externalRef}/revoke, GET /products
passes.redeem POST /passes/verify, POST /passes/redeem (with or without a reference), GET /redemptions
wallet.charge POST /charges
either passes.* scope GET /passes, GET /passes/{externalRef}, GET /accounts/{taabId}/passes

By reference you only ever see passes you issued. Another client's reference, or a pass a Ta'ab admin issued by hand, reads as pass_not_found. The passes a holder linked to you holds, that you issued or honour, are GET /accounts/{taabId}/passes.

Credentials

Wherever a holder presents something, send it as one of:

{ "type": "nfc", "uid": "04A22B9151C280" }
{ "type": "qr",  "token": "<the string decoded from the Ta'ab app's QR>" }
  • Send the raw value, in the POST body. Never a hash, and never in a URL, where it would land in access logs.

  • NFC: send the tag's UID, never an NDEF text record. Any spelling is accepted: 04A22B91, 04:a2:2b:91, 04 A2 2B 91. Ta'ab normalises it with this rule:

    strip every character that is not [0-9a-fA-F]
    uppercase the remainder
    reject unless 8 ≤ length ≤ 20 and length is even     (4–10 bytes)
    

    Byte order is whatever the reader reports. Do not reverse it.

  • QR: the token rotates every 60 seconds. A token that has been superseded is stale, not unknown: ask the holder to let the app refresh, and scan again. The QR is the bare token with no prefix. It is not a UUID.

Passes

A pass is issued to one holder, under your own reference (externalRef), and belongs to your institution. A holder can have any number of your passes at once.

There are two ways to say what a pass is:

  • From your catalog. Send a productId from GET /products. The product decides the pass's type, how many uses it has and how long it lasts. This is how to issue a monthly pass or a ten-visit card.
  • Spelled out. Send validFrom, validUntil, and optionally type and uses. This is how to issue a ticket for one trip or one event. type defaults to transit and uses to 1; uses: null means unlimited within the window. Use type: "general" when no other type fits.

A product's days start when the pass is issued, or the first time it is redeemed: startsOn in GET /products says which. A pass waiting for its first use has a validUntil a year out, which is only the deadline for using it once. Its first approved redemption replaces that with the real end of its window.

A product can be for some concession profiles only (a student bus pass): concessions in GET /products lists them, null for anyone. A holder has the concessions their organisations' rosters give them (a school's roster says student). Issuing such a product to anyone else answers 409 concession_required, and nothing is issued. A spelled-out pass has no such rule.

metadata is shown on the pass in the Ta'ab app and Ta'ab never interprets it. Five keys are layout hints the app knows: label, origin, destination, departureAt and seat. You can add up to 20 keys of your own, with string, number, boolean or null values.

A pass's status records what was done to it: active, expired or revoked. A ticket that has been used, or whose window has closed, still says active; check remainingUses and validUntil for what it can still do.

Redeeming needs the holder's credential, so there is no way to mark a pass used on their behalf. If you admit someone by other means, such as scanning your own ticket code, the pass stays unused and lapses when its window closes. Revoke it if you want it gone sooner.

Selling the same product again: renewal

A holder whose monthly pass ends on Friday can buy next month on Wednesday. When you POST /passes with a productId that counts no uses (uses: null in GET /products), for a holder who is already using a pass you sold them of that product, Ta'ab does not issue a second pass. It renews theirs:

  • The answer is that pass, with its own externalRef and a validUntil later by the product's durationDays, and renewal: { externalRef, addedDays } naming your sale. renewal is null whenever a new pass was issued. A validFrom you send is not used: a renewal always continues from where the pass ends.
  • Your sale's externalRef is its key, as for any issue: sending it again returns the same renewal with replayed: true, and GET /passes/{externalRef} with it reads the pass.
  • Refunding a renewal: POST /passes/{externalRef}/revoke with the renewal's reference takes its days back off, and the pass stays active. Once those days have begun, the answer is 409 conflict. Revoking the first sale's reference revokes the pass itself, renewals and all.
  • 409 already_held: the holder has a pass of this product that can't be renewed by you: it hasn't started yet (it waits for its first use, or begins later), or someone else sold it to them. Selling it again would leave them two.

A product that counts uses (a ten-ride card) is not renewed: each sale is a pass of its own.

Passes you accept but did not issue

Some passes are honoured by several institutions: a T-Pass belongs to a transit authority and every bus company on its list accepts it. You have no reference for such a pass, so you redeem it without one.

  • POST /passes/verify returns acceptedPasses beside passes: what this holder could use with you right now without naming a pass. It is there for your screen ("T-Pass, valid until 12 Nov"). You never need its passId.
  • POST /passes/redeem without externalRef uses the holder's best pass of type (default transit) that your institution accepts. A pass that counts nothing is used before one that counts rides; between two alike, the one that lapses sooner. The answer has externalRef: null and the passId that was used.
  • operatorRef says who carried the rider when you front several operators, such as a ticketing system with many bus companies. It is your own name or id for them, up to 80 characters. Ta'ab records it and groups its usage report by it; send the same value every time for the same operator.

A ticket is never used this way. A ticket is a pass you issued spelled out, for one trip or one event: it is redeemed by its externalRef and nothing else, so a redemption without a reference can't spend it.

A pass you sold from your catalog is different. When you issue a productId (a monthly T-Pass, say) under your sale's reference, the pass is the holder's like any other: a redemption without a reference can use it, at your own institution and at every institution that accepts the product, and verify lists it in both passes and acceptedPasses. You can still read it back or revoke it by its externalRef. It lapses with its window; revoking it after that answers conflict, since it is already expired.

Each entry of acceptedPasses says whose it is and what a sale would do: relation is issued for your institution's pass and accepted for another institution's that yours honours, and renewable is true when selling its product again renews it.

passes holds only the passes this client issued. A pass of your product that your staff issued in the admin portal is in acceptedPasses alone, and selling that holder the product again answers already_held, because only a pass you sold is renewed by your sale. So before you take money for a renewal, check renewable on the pass in acceptedPasses: true, a sale renews it; false, the sale would answer already_held (someone else sold it, or it hasn't started yet).

When the holder has nothing you accept, the answer is a denial like any other: denied_expired with passId: null when they hold no pass of that type at all, or denied_wrong_institution when they hold one that you are not on the list for.

Which institutions accept a product is set by Ta'ab, with each institution's agreement. Ta'ab moves no money between you: it counts redemptions, and the pass's owner can export them by institution and by operatorRef.

Idempotency

Both writes that matter are safe to retry.

  • Issuing: externalRef is the key. Sending the same one again, as a retry or as two requests racing, returns the original pass (or renewal) with 200 and replayed: true. The reference is permanent: a revoked pass keeps it, so re-issuing needs a new one.
  • Redeeming: idempotencyKey is the key. Reusing a key returns the original decision with replayed: true and consumes nothing further. Use one key per real-world redemption (your ticket id is a good choice) and retry freely on a timeout.

Concurrent redemptions of a single-use pass approve exactly once.

Errors and denials

Errors are { "code": "...", "message": "..." }. Branch on code; message is for people and may be reworded.

Status Code Meaning
400 invalid_input The body, query or path is malformed, or a uid isn't a card UID
401 unauthorized Missing, wrong or disabled client credentials
403 forbidden Valid client, but it lacks the scope this route needs
404 credential_not_found No Ta'ab card has that UID, or the QR is not one Ta'ab issued. On cards/activate: none of your card stock has that UID
404 account_not_found No account has that Ta'ab ID
404 pass_not_found You have no pass with that externalRef
404 product_not_found You have no product with that productId
409 card_not_assigned A real Ta'ab card, but in stock, returned, lost or retired
409 credential_stale The QR token has been superseded by a newer one
409 credential_revoked The QR credential was revoked (a lost phone)
409 account_inactive The account is suspended, closed or scrubbed
409 account_unlinked On POST /passes: the account has no login and no card, so nobody could present the pass. A walk-in card that was reported lost before anyone claimed it
409 already_held The holder has a pass of this product that this sale can't renew (see "Selling the same product again")
409 concession_required The product is for some concession profiles only (concessions in GET /products), and the holder has none of them
409 conflict The externalRef is already used for a different account, the product is archived, a renewal's days have begun so it can't be revoked, or the card you are activating is someone else's, lost or retired
429 rate_limited Too many requests this minute

A denied redemption is not an error. It is HTTP 200 with result: "denied_…":

Result Meaning
approved One use consumed (or, for an unlimited pass, the visit recorded)
denied_no_uses_remaining Already fully used
denied_expired occurredAt is outside the pass's validity window. Without an externalRef: the holder has no pass of that type, or theirs has lapsed
denied_wrong_institution Without an externalRef only: the holder has a valid pass of that type, but none your institution accepts
denied_revoked The pass was revoked, or the card was returned or reported lost
denied_not_holder The credential belongs to a different account than the pass
denied_recently_used An unlimited transit pass already used with you, for the same operatorRef, in the last 10 minutes: one card is carrying a second rider. Tell them to show their own pass. A ride with another operator is not affected
denied_daily_limit The pass's uses for today are spent: a pass with a dailyLimit that many times today, the day being its institution's. It works again after that institution's midnight
denied_suspended The presenter's account is not in good standing
denied_unknown_credential A QR token that has since rotated. Scan again

Treat a 4xx as "the request was wrong" and a denial as "the request was fine and the answer is no". Every denial is recorded in the holder's Ta'ab history. Only an unknown credential or an unknown pass is a 404, because there is nothing to record it against.

Rate limit

A client may make 600 requests a minute. Past that, requests answer 429 rate_limited with a Retry-After header giving the seconds until the next minute starts. This is a guard against a runaway loop, not a quota: a busy boarding queue is a few requests a second.

Timeouts, retries and outages

A tap is waiting on the answer, so use a short timeout: about five seconds.

  • A timeout, a network error, a 5xx or a 429 says nothing about the holder. Retry with the same externalRef or idempotencyKey. Never show it as a refusal.
  • A 401 or 403 is about you, not the holder. Your credentials or scopes are wrong. Log it loudly and treat it as an outage at the door.
  • Never make a sale wait on Ta'ab. Issue after the sale has gone through, and keep retrying until it succeeds. The simplest way is to work from state: on a schedule, look for paid tickets with no pass and issue them, and for dead tickets with a live pass and revoke them. Both calls are idempotent, so the sweep can run as often as you like.
  • Decide in advance what the door does when Ta'ab can't be reached. If you can identify the holder another way, such as your own ticket's code, admit them on that.

Paging

GET /passes returns your passes newest first. Follow nextCursor until it is null.

GET /redemptions returns your redemptions oldest first. It always returns a nextCursor you can resume from, even when you are caught up, and hasMore says whether another page is ready now. To follow new redemptions, keep the last cursor and ask again with it.

A cursor is opaque. A page never skips or repeats a row, however much is added while you page.

Linking an account

With the connections.link scope, POST /links links a holder's Ta'ab account to yours, in one of two ways.

With the card in hand. Send the card's UID, or the Ta'ab app's QR, as credential. Presenting it is the holder's consent: the answer is 201 with status: "approved", their taabId, and the card that was tapped (its printed cardNumber). Its errors are those of identity/resolve. Linking the same holder again reuses the link and answers a new linkId.

Without a card, the user links by saying yes in Ta'ab. No card, QR or staff member is involved.

  1. POST /links. You get a linkId, a url and a code. They are valid for 10 minutes, and url and code are shown only in this answer.
  2. Send the user to url: a redirect from your web app, a link in your mobile app, or a QR on a screen. If they can't open it (a kiosk, a POS), show code instead; they type it at /connect in Ta'ab. Either way they sign in to Ta'ab, see your app's name and what you will learn, and approve or decline. Someone without a Ta'ab wallet gets one as they approve.
  3. GET /links/{linkId} until status is no longer pending. On approved, store taabId against your user.

To get the user back to you, register one or more return URLs on your client under API clients in Ta'ab, and send one as returnUrl in step 1. It must match a registered URL exactly. A return URL is an https address, or your mobile app's own scheme, such as yourapp://taab/linked, so a phone app gets the user back after they answer in the browser. When the user answers, Ta'ab sends them there with link and status added to the query. Treat that as a hint to call GET /links/{linkId}, never as the answer itself.

Either way, you learn the Ta'ab ID and the passes they hold that you issue or honour (below), never their balance, history or other passes. The holder sees your app in their wallet and can unlink it at any time. The link then reads revoked with no taabId. Passes you already issued stay valid; stop issuing new ones to that Ta'ab ID.

Keep the linkId. A card you linked may be a walk-in card that its rider later claims into the Ta'ab account they already had. The link follows the card, and GET /links/{linkId} answers with the Ta'ab ID it now belongs to.

Passes a linked holder holds

GET /accounts/{taabId}/passes (either passes.* scope) lists, for a holder linked to you or on your institution's list (a roster, or a card your counter activated), the live passes your institution issued or honours, whoever sold them: a T-Pass a bus company sold, a company's pass for its staff that you accept, your own tickets. Each has the same fields as an entry of acceptedPasses, relation and renewable included. Those usable now come first, in the order Ta'ab would spend them; those that start later follow.

This is what a page showing a rider their T-Pass reads: find the pass of your product, show its validUntil (or, with windowDays set, "starts on your first ride"), and offer Renew only when it is renewable. A holder who isn't linked to you answers 409 not_connected; link them with POST /links.

Riding with no signal

A bus that loses signal can still board riders whose passes your institution honours.

  1. While online, keep a list. GET /offline/passes?type=transit (scope passes.redeem, paged by cursor) lists every holder with a pass you honour at some point in the next 24 hours: their taabId, the sha256 of each of their cards' UIDs (normalised: hex digits only, uppercase), and the passes. Refresh it every few minutes while you have signal, and stop taking offline rides after its honourUntil.
  2. Keep Ta'ab's key. With no signal, the Ta'ab app shows an offline code instead of its rotating QR: o1.{kid}.{taabId}.{window}.{signature}. GET /offline/key gives the Ed25519 public key that signs it. The signature covers everything before the last dot; window is floor(unix seconds / 600); accept the window either side of your clock's.
  3. With no signal, decide on the device. Accept a card whose UID hash is listed, or an offline code that verifies and whose Ta'ab ID is listed. Refuse a second tap of the same holder within 10 minutes on the same operator, as Ta'ab does.
  4. Upload each ride once you are back. passes/redeem with offline: true, the tap's own occurredAt (within 72 hours) and an idempotencyKey made on the device. The ride stands: the result is approved, and offlineDenial says what Ta'ab would have answered at the time (a pass revoked since the list was made, a second tap elsewhere), or null. Ta'ab's usage report counts it as a ride and lists the flagged ones apart.

Online, an offline code is accepted wherever a QR is, in its own window only.

Cards for riders without a phone

With the cards.activate scope, your counter can hand a rider a working Ta'ab card on the spot: no Ta'ab login, no phone, no form. Ta'ab allocates card stock to your institution; ask for it. The cards are listed under NFC cards in the admin portal, where your staff can do the same from a browser ("Walk-in").

  1. Tap the card and POST /cards/activate with its uid. You get 201 with the card's taabId, its printed cardNumber, and a claimCode. The card works from now on.
  2. Sell them a pass as for anyone: POST /passes to that taabId.
  3. Print claimCode on their slip. It is shown only in this answer. If the answer never reached you, call again with "newClaimCode": true for a fresh one; the old one stops working.

Activating a card that is already one of yours answers 200 with replayed: true, its taabId, and claimCode: null. So a retry is safe, and a sale can start from any card the rider already holds.

Before it is activated, a card from your stock answers card_not_assigned on identity/resolve and passes/verify, as a lost or returned card does. So a counter that reads a card first can offer "sell it as a new card" on that code, and let cards/activate decide: it activates a card in stock or returned, and answers conflict for one that is lost, retired or someone else's.

Claiming. Whenever the rider likes, they enter the code in the Ta'ab app or at /activate. Someone new takes the card's account over and keeps its Ta'ab ID. Someone who already has a Ta'ab account gets the card and its passes moved onto theirs, and from then on the card answers with their Ta'ab ID. So find a returning rider by tapping their card (identity/resolve), or by a link you made with the card (GET /links/{linkId}), never by a Ta'ab ID you stored at the sale.

A card lost before anyone claims it is gone with its passes: nobody can prove it was theirs. Report it lost in the admin portal and it stops working at once. After that, POST /passes to its Ta'ab ID answers account_unlinked.

Riders on walk-in cards are on your institution's list in the admin portal as "Walk-in card", apart from your roster: giving everyone on the roster a pass leaves them out.

Charging a balance

Some holders keep a balance with an organisation: a school's cafeteria money, added in cash at its counter. A client with the wallet.charge scope (a canteen's till app) charges that balance when the holder presents their card or QR: POST /charges with the credential, amountCents, your own idempotencyKey and, optionally, a reason the holder reads on their statement. The card is the consent, as for a redemption.

The answer is approved with balanceAfterCents, or declined with a reason: insufficient_balance (also when the holder keeps no balance with you), over_daily_limit (a guardian's limit), account_inactive, credential_revoked, purse_frozen. A decline is HTTP 200 and takes nothing. Until Ta'ab holds a payment licence, a balance belongs to the organisation it was added at and is spent only there: you can charge only the balance kept with your own institution, and licence_required and wrong_organisation are reserved for once that changes.

Suggested flows

Link an account. With a card: the user taps it, or staff scan their Ta'ab QR. Call POST /links with it as credential and store the linkId and taabId on your user. Refuse to link on any error: that is what makes the card a genuine Ta'ab card and not just any NFC tag. Link again whenever the user links a card, and keep the newest: a card can be returned and assigned to someone else. Without a card: see "Linking an account" above.

Show a rider their passes. Read GET /links/{linkId} for their current taabId, then GET /accounts/{taabId}/passes.

Sell a ticket. Once the booking is paid, POST /passes with the ticket number as externalRef. Do this out of band and retry on failure. It is idempotent, and a Ta'ab outage must not block a sale. Revoke on cancel or refund.

Sell a membership or a monthly pass. Create the product once in the Ta'ab admin portal. POST /passes with its productId and your own reference for the sale. Sell it again the same way for the next period; Ta'ab renews the pass the holder is using.

Sell to a walk-in at the counter. Tap a card from your stock, cards/activate, then POST /passes to the returned taabId and print the claim code on the slip. Next month, tap the card, identity/resolve, and sell again to the Ta'ab ID it answers with.

Accept a shared pass. Card tap or Ta'ab QR, then passes/redeem with no externalRef, your operatorRef, and a per-ride idempotencyKey. On approved, let them on. Do this when you have no ticket of your own for the rider; if you do, redeem the ticket.

Redeem. Card tap or Ta'ab QR, then passes/verify. Match one of the returned externalRefs to what is happening now, run your own checks, then passes/redeem with a per-redemption idempotencyKey. On approved, let them in.

passes/verify tells you who is presenting even when passes is empty. With the taabId you stored at linking you can still find their booking, which covers a ticket whose pass has not reached Ta'ab yet, and an unpaid booking you want to collect for. A QR can rotate between verify and redeem; that redemption answers denied_unknown_credential, and the holder scans again.

Reconcile. Poll GET /redemptions with your last cursor and compare against your own records.

Changes

  • November 2026, concession profiles. concessions on GET /products (null for anyone) and 409 concession_required on POST /passes for a holder a product isn't for. Every product is for anyone until its organisation says otherwise, so nothing you issue changes. Nothing was removed or renamed.
  • November 2026, charging a balance. The wallet.charge scope and POST /charges. Nothing was removed or renamed.
  • October 2026, a daily limit. dailyLimit on passes, on acceptedPasses and on GET /products: how many times a day a pass can be used, null for no limit. A new result, denied_daily_limit, past it. Only a product its institution gives a limit has one; nothing you issue changes. Nothing was removed or renamed.
  • October 2026, a linked card shows its passes. credential on POST /links, which links at once; status, taabId and card on its answer (code and url are null only when you send a credential); a link made with a card follows it when it is claimed into another account; GET /accounts/{taabId}/passes and 409 not_connected; validFrom, externalRef, relation and renewable on each of verify's acceptedPasses. Nothing was removed or renamed.
  • October 2026, riding with no signal. GET /offline/passes, GET /offline/key, the offline QR code (o1.…, accepted as a qr credential), offline: true on passes/redeem, the fields offline and offlineDenial on its answer and on GET /redemptions, and the error offline_unavailable. Nothing was removed or renamed.
  • October 2026, cards for riders without a phone. The cards.activate scope and POST /cards/activate. An account with no Ta'ab login is now a holder like any other: identity/resolve and passes/verify answer for its card where they used to say account_unlinked, and POST /passes returns account_unlinked only for an account with no login and no card. Return URLs may use your app's own scheme. Nothing was removed or renamed.
  • October 2026, renewals. Selling a product that counts no uses to a holder already using the pass you sold them of it renews that pass. Added renewal to the POST /passes answer (null for a new pass), 409 already_held, and revoking a renewal by its reference. Nothing was removed or renamed.
  • October 2026, selling passes. A pass you issue from a productId is no longer treated as a ticket: a redemption without a reference can use it, verify lists it in acceptedPasses too, and it is marked expired when its window closes. Passes issued spelled out are unchanged. No request or answer changed shape.
  • October 2026, one rider per pass. A new result, denied_recently_used: an unlimited transit pass is refused a second ride with the same operator within 10 minutes. Treat a result you don't know as a refusal you can't explain, never as approval.
  • October 2026, linking. The connections.link scope, POST /links, GET /links/{linkId}, and 404 link_not_found. Nothing was removed or renamed.
  • October 2026, self-service. The sandbox; owners create, rotate and disable their own clients. No change to any request or answer.
  • October 2026, shared passes. Passes you accept but did not issue: acceptedPasses on passes/verify; externalRef is now optional on passes/redeem, with type and operatorRef; operatorRef on each row of GET /redemptions; startsOn on each product. In a redeem answer, externalRef and passId can be null, but only when you sent no externalRef. Nothing was removed or renamed.
  • October 2026. Added type, productId, uses: null and free-form metadata on issuing; type and productId on every pass; GET /passes, GET /passes/{externalRef}, GET /redemptions, GET /products; the rate limit and 429 rate_limited; 404 product_not_found. Metadata keys other than the five hints used to be dropped and are now kept. Nothing was removed or renamed.

Partner API reference

Generated from the schemas the API validates with, so it cannot drift from what the API does. The same content, machine-readable: openapi.json.

POST /identity/resolve

Whose card or QR is this?

Tells you whether a credential is a genuine, assigned Ta'ab card or a live Ta'ab QR, and returns the holder's Ta'ab ID. Use it to link one of your users to their Ta'ab account.

Scope identity.resolve read-only: writes nothing

Request body

FieldTypeNotes
credentialobjectWhat the holder presented: a card tap or the Ta'ab app's QR. Send the raw value, never a hash.
credential.type"nfc" | "qr"Which kind was presented.
credential.uidstringRequired when type is "nfc". The card's UID exactly as the reader reports it. Any spelling is accepted: 04A22B91, 04:a2:2b:91, 04 A2 2B 91.
credential.tokenstringRequired when type is "qr". The string decoded from the QR in the Ta'ab app. It rotates every 60 seconds; with no signal the app shows an offline code (o1.…) instead, good for 10 minutes, which Ta'ab takes too.

Example

curl -X POST "https://sandbox.taab.mohbel.com/api/partner/v1/identity/resolve" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"credential":{"type":"nfc","uid":"04:a2:2b:91:51:c2:80"}}'

Answer 200

The credential is active, on an active, activated account.

FieldTypeNotes
taabIdstringThe holder's permanent Ta'ab ID. Store it against your own user.
accountStatusstringAlways active on a 200.
credentialType"nfc" | "qr"
cardobject | nullThe card behind an NFC tap. null for a QR.
card.cardNumberstringThe number printed on the card.
card.statusstringAlways assigned on a 200.
{
  "taabId": "TB-00000001234",
  "accountStatus": "active",
  "credentialType": "nfc",
  "card": {
    "cardNumber": "C-0041",
    "status": "assigned"
  }
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 credential_not_found — No Ta'ab card has that UID, or the QR is not one Ta'ab issued.
  • 409 card_not_assigned — A real Ta'ab card, but in stock, returned, lost or retired.
  • 409 credential_stale — The QR token has been superseded by a newer one. Ask the holder to refresh and scan again.
  • 409 credential_revoked — The QR credential was revoked (a lost phone).
  • 409 account_inactive — The account is suspended, closed or scrubbed.
  • 409 account_unlinked — The account has no login and no card, so nobody could present a pass on it.
POST /passes

Issue a pass

Puts a pass in a holder's wallet, keyed by your own reference. Either name one of your catalog products, or spell the pass out with type, validFrom, validUntil and uses. Sending the same externalRef again returns the original pass with 200 and replayed: true, so retries are safe. Selling a product that counts no uses to a holder already using the pass you sold them of it renews that pass instead: its validUntil moves on by the product's duration, and renewal says so.

Scope passes.issue

Request body

FieldTypeNotes
taabIdstringThe holder's Ta'ab ID, as returned by identity/resolve.
externalRefstringYour own reference for the pass, such as a ticket number. Letters, digits and . _ : - only. It is permanent and is your idempotency key for issuing.
productIdstringoptionalIssue from one of your catalog products (see GET /products). The product decides the pass's type, uses and length, so type, uses and validUntil can't be sent with it.
type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"optionalWhat kind of pass this is. Defaults to transit. Use general when none of the others fits.
validFromtimestampoptionalWhen the pass starts being valid. Required unless productId is sent, where it defaults to now.
validUntiltimestampoptionalWhen the pass stops being valid. Required unless productId is sent.
usesinteger | nulloptionalHow many times the pass can be redeemed, 1 to 100. Defaults to 1. null means unlimited within the validity window.
metadataobjectoptionalDisplay data for the pass in the Ta'ab app. Five keys are layout hints (label, origin, destination, departureAt, seat); you may add up to 20 keys of your own with string, number, boolean or null values.
metadata.labelstringoptionalHeadline shown on the pass.
metadata.originstringoptionalWhere a trip starts.
metadata.destinationstringoptionalWhere a trip ends.
metadata.departureAttimestampoptionalWhen a trip leaves (ISO 8601).
metadata.seatstringoptionalSeat or place.
metadata.*string | number | boolean | nulloptionalAny other key of your own.

Example

curl -X POST "https://sandbox.taab.mohbel.com/api/partner/v1/passes" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"taabId":"TB-00000001234","externalRef":"TKT-8842-1","type":"transit","validFrom":"2026-10-14T12:00:00Z","validUntil":"2026-10-14T16:00:00Z","uses":1,"metadata":{"label":"Belize City → Orange Walk","origin":"Belize City","destination":"Orange Walk","departureAt":"2026-10-14T13:30:00.000Z","seat":"12"}}'

Answer 201

The pass was created, or renewed. 200 with replayed: true when the externalRef already existed.

FieldTypeNotes
passIdstringTa'ab's id for the pass.
externalRefstringYour reference for it.
type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"
productIdstring | nullThe catalog product it was issued from, or null.
status"active" | "expired" | "revoked"What was done to the pass. A ticket that is used up, or past its window, still says active here; check remainingUses and validUntil.
validFromtimestamp
validUntiltimestamp
remainingUsesinteger | nullUses left. null means unlimited within the window.
dailyLimitinteger | nullHow many times a day the pass can be used, the day being its institution's; past it a redemption answers denied_daily_limit. null means no limit.
metadataobjectThe display data you sent.
renewalobject | nullSet when this sale renewed the pass the holder was already using: the pass is that one, with its own externalRef and the later validUntil. null for a new pass.
renewal.externalRefstringYour reference for this sale.
renewal.addedDaysintegerHow many days it added to the pass's validUntil.
replayedbooleantrue when this externalRef already existed and what it did the first time is returned.
{
  "passId": "0b9c2f6e-6f0e-4c0f-9a6e-2f1f0b7c9d11",
  "externalRef": "TKT-8842-1",
  "type": "transit",
  "productId": null,
  "status": "active",
  "validFrom": "2026-10-14T12:00:00.000Z",
  "validUntil": "2026-10-14T16:00:00.000Z",
  "remainingUses": 1,
  "dailyLimit": null,
  "metadata": {
    "label": "Belize City → Orange Walk",
    "origin": "Belize City",
    "destination": "Orange Walk",
    "departureAt": "2026-10-14T13:30:00.000Z",
    "seat": "12"
  },
  "renewal": null,
  "replayed": false
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 account_not_found — No account has that Ta'ab ID.
  • 409 account_inactive — The account is suspended, closed or scrubbed.
  • 409 account_unlinked — The account has no login and no card, so nobody could present a pass on it.
  • 404 product_not_found — You have no product with that productId.
  • 409 already_held — The holder already has a pass from this product that can't be renewed by this sale: it hasn't started yet, or someone else sold it.
  • 409 concession_required — The product is only for some concession profiles (students, seniors, staff), and the holder's roster places give them none of those.
  • 409 conflict — The externalRef is already used for a different account, the product is archived, or a renewal's days have begun so it can't be revoked.
GET /passes

List your passes

The passes you issued, newest first, a page at a time. Follow nextCursor until it is null.

Scope passes.issueor passes.redeem read-only: writes nothing

Query

FieldTypeNotes
taabIdstringoptionalOnly this holder's passes.
status"active" | "expired" | "revoked"optionalOnly passes with this stored status.
limitintegeroptionalHow many to return, 1 to 200. Defaults to 50.
cursorstringoptionalThe nextCursor from the previous page.

Example

curl "https://sandbox.taab.mohbel.com/api/partner/v1/passes?limit=50" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET"

Answer 200

A page of passes.

FieldTypeNotes
passesarray of objectNewest first.
passes[].passIdstringTa'ab's id for the pass.
passes[].externalRefstringYour reference for it.
passes[].type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"
passes[].productIdstring | nullThe catalog product it was issued from, or null.
passes[].status"active" | "expired" | "revoked"What was done to the pass. A ticket that is used up, or past its window, still says active here; check remainingUses and validUntil.
passes[].validFromtimestamp
passes[].validUntiltimestamp
passes[].remainingUsesinteger | nullUses left. null means unlimited within the window.
passes[].dailyLimitinteger | nullHow many times a day the pass can be used, the day being its institution's; past it a redemption answers denied_daily_limit. null means no limit.
passes[].metadataobjectThe display data you sent.
nextCursorstring | nullSend as cursor for the next page. null on the last page.
{
  "passes": [
    {
      "passId": "0b9c2f6e-6f0e-4c0f-9a6e-2f1f0b7c9d11",
      "externalRef": "TKT-8842-1",
      "type": "transit",
      "productId": null,
      "status": "active",
      "validFrom": "2026-10-14T12:00:00.000Z",
      "validUntil": "2026-10-14T16:00:00.000Z",
      "remainingUses": 1,
      "dailyLimit": null,
      "metadata": {
        "label": "Belize City → Orange Walk",
        "origin": "Belize City",
        "destination": "Orange Walk",
        "departureAt": "2026-10-14T13:30:00.000Z",
        "seat": "12"
      }
    }
  ],
  "nextCursor": null
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 account_not_found — No account has that Ta'ab ID.
GET /passes/{externalRef}

Read one pass

One of your passes as it stands now.

Scope passes.issueor passes.redeem read-only: writes nothing

Path

  • externalRef — Your reference for the pass.

Example

curl "https://sandbox.taab.mohbel.com/api/partner/v1/passes/TKT-8842-1" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET"

Answer 200

The pass.

FieldTypeNotes
passIdstringTa'ab's id for the pass.
externalRefstringYour reference for it.
type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"
productIdstring | nullThe catalog product it was issued from, or null.
status"active" | "expired" | "revoked"What was done to the pass. A ticket that is used up, or past its window, still says active here; check remainingUses and validUntil.
validFromtimestamp
validUntiltimestamp
remainingUsesinteger | nullUses left. null means unlimited within the window.
dailyLimitinteger | nullHow many times a day the pass can be used, the day being its institution's; past it a redemption answers denied_daily_limit. null means no limit.
metadataobjectThe display data you sent.
{
  "passId": "0b9c2f6e-6f0e-4c0f-9a6e-2f1f0b7c9d11",
  "externalRef": "TKT-8842-1",
  "type": "transit",
  "productId": null,
  "status": "active",
  "validFrom": "2026-10-14T12:00:00.000Z",
  "validUntil": "2026-10-14T16:00:00.000Z",
  "remainingUses": 1,
  "dailyLimit": null,
  "metadata": {
    "label": "Belize City → Orange Walk",
    "origin": "Belize City",
    "destination": "Orange Walk",
    "departureAt": "2026-10-14T13:30:00.000Z",
    "seat": "12"
  }
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 pass_not_found — You have no pass with that externalRef.
POST /passes/{externalRef}/revoke

Revoke a pass

Call it when a booking is cancelled, refunded or lapses. Revoking twice is harmless: the second call returns replayed: true. A renewal's reference takes that renewal's days back off the pass, which stays active, and is refused once those days have begun.

Scope passes.issue

Path

  • externalRef — Your reference for the pass.

Request body

FieldTypeNotes
reasonstringWhy the pass is being revoked. Shown to Ta'ab admins.

Example

curl -X POST "https://sandbox.taab.mohbel.com/api/partner/v1/passes/TKT-8842-1/revoke" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"reason":"booking cancelled"}'

Answer 200

The pass, now revoked, or shorter by the renewal.

FieldTypeNotes
passIdstringTa'ab's id for the pass.
externalRefstringYour reference for it.
type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"
productIdstring | nullThe catalog product it was issued from, or null.
status"active" | "expired" | "revoked"What was done to the pass. A ticket that is used up, or past its window, still says active here; check remainingUses and validUntil.
validFromtimestamp
validUntiltimestamp
remainingUsesinteger | nullUses left. null means unlimited within the window.
dailyLimitinteger | nullHow many times a day the pass can be used, the day being its institution's; past it a redemption answers denied_daily_limit. null means no limit.
metadataobjectThe display data you sent.
replayedbooleantrue when it was already revoked and is returned unchanged.
{
  "passId": "0b9c2f6e-6f0e-4c0f-9a6e-2f1f0b7c9d11",
  "externalRef": "TKT-8842-1",
  "type": "transit",
  "productId": null,
  "status": "revoked",
  "validFrom": "2026-10-14T12:00:00.000Z",
  "validUntil": "2026-10-14T16:00:00.000Z",
  "remainingUses": 1,
  "dailyLimit": null,
  "metadata": {
    "label": "Belize City → Orange Walk",
    "origin": "Belize City",
    "destination": "Orange Walk",
    "departureAt": "2026-10-14T13:30:00.000Z",
    "seat": "12"
  },
  "replayed": false
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 pass_not_found — You have no pass with that externalRef.
  • 409 conflict — The externalRef is already used for a different account, the product is archived, or a renewal's days have begun so it can't be revoked.
POST /passes/verify

Who is presenting, and what can they use?

Returns the holder and the passes of yours they could still use. A pass for a later trip is included: you pick the one that matches what is happening now, then redeem it. acceptedPasses lists what they could use with you without naming a pass: passes your institution accepts, such as a T-Pass, including one you sold from your own catalog. Never a ticket.

Scope passes.redeem read-only: writes nothing

Request body

FieldTypeNotes
credentialobjectWhat the holder presented: a card tap or the Ta'ab app's QR. Send the raw value, never a hash.
credential.type"nfc" | "qr"Which kind was presented.
credential.uidstringRequired when type is "nfc". The card's UID exactly as the reader reports it. Any spelling is accepted: 04A22B91, 04:a2:2b:91, 04 A2 2B 91.
credential.tokenstringRequired when type is "qr". The string decoded from the QR in the Ta'ab app. It rotates every 60 seconds; with no signal the app shows an offline code (o1.…) instead, good for 10 minutes, which Ta'ab takes too.

Example

curl -X POST "https://sandbox.taab.mohbel.com/api/partner/v1/passes/verify" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"credential":{"type":"qr","token":"kZ04_mA2-n2Bp91qRsTuVwXyZ..."}}'

Answer 200

The holder and their usable passes.

FieldTypeNotes
holderobjectWho is presenting.
holder.taabIdstringThe holder's permanent Ta'ab ID. Store it against your own user.
holder.accountStatusstringAlways active on a 200.
holder.credentialType"nfc" | "qr"
holder.cardobject | nullThe card behind an NFC tap. null for a QR.
holder.card.cardNumberstringThe number printed on the card.
holder.card.statusstringAlways assigned on a 200.
passesarray of objectYour active passes for this holder that have not passed validUntil, soonest first.
passes[].passIdstringTa'ab's id for the pass.
passes[].externalRefstringYour reference for it.
passes[].type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"
passes[].productIdstring | nullThe catalog product it was issued from, or null.
passes[].status"active" | "expired" | "revoked"What was done to the pass. A ticket that is used up, or past its window, still says active here; check remainingUses and validUntil.
passes[].validFromtimestamp
passes[].validUntiltimestamp
passes[].remainingUsesinteger | nullUses left. null means unlimited within the window.
passes[].dailyLimitinteger | nullHow many times a day the pass can be used, the day being its institution's; past it a redemption answers denied_daily_limit. null means no limit.
passes[].metadataobjectThe display data you sent.
acceptedPassesarray of objectPasses this holder could use with you right now without naming one: a T-Pass, a membership your institution accepts, or a pass you sold from your own catalog. Never a ticket. In the order Ta'ab would spend them. Redeem one by calling passes/redeem without externalRef.
acceptedPasses[].passIdstringTa'ab's id for the pass.
acceptedPasses[].type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"
acceptedPasses[].productIdstring | nullThe catalog product it was issued from, or null.
acceptedPasses[].namestringWhat to call it on a screen: the product's name, or the kind of pass.
acceptedPasses[].validFromtimestampWhen it starts being valid.
acceptedPasses[].validUntiltimestampWhen it stops being valid. For a pass that starts on first use and has not been used, the deadline for that first use (see windowDays).
acceptedPasses[].remainingUsesinteger | nullUses left. null means unlimited within the window.
acceptedPasses[].windowDaysinteger | nullSet only while a pass that starts on first use has not been used: how many days it will run from its first redemption. null otherwise.
acceptedPasses[].dailyLimitinteger | nullHow many times a day the pass can be used, the day being its institution's; past it a redemption answers denied_daily_limit. null means no limit.
acceptedPasses[].externalRefstring | nullYour reference for it, when you issued it; null for a pass someone else issued.
acceptedPasses[].relation"issued" | "accepted"issued: your institution's pass, whoever sold it. accepted: another institution's pass that yours honours.
acceptedPasses[].renewablebooleantrue when selling its product again (POST /passes with its productId) renews this pass: you sold it, its product counts no uses, and it has started. false for a pass of that product you can't renew: selling it would answer already_held.
{
  "holder": {
    "taabId": "TB-00000001234",
    "accountStatus": "active",
    "credentialType": "qr",
    "card": null
  },
  "passes": [
    {
      "passId": "0b9c2f6e-6f0e-4c0f-9a6e-2f1f0b7c9d11",
      "externalRef": "TKT-8842-1",
      "type": "transit",
      "productId": null,
      "status": "active",
      "validFrom": "2026-10-14T12:00:00.000Z",
      "validUntil": "2026-10-14T16:00:00.000Z",
      "remainingUses": 1,
      "dailyLimit": null,
      "metadata": {
        "label": "Belize City → Orange Walk",
        "origin": "Belize City",
        "destination": "Orange Walk",
        "departureAt": "2026-10-14T13:30:00.000Z",
        "seat": "12"
      }
    }
  ],
  "acceptedPasses": [
    {
      "passId": "7c1d4e9a-2b3f-4c5d-8e6f-0a1b2c3d4e5f",
      "type": "transit",
      "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "name": "T-Pass 30 days",
      "validUntil": "2026-11-12T06:00:00.000Z",
      "remainingUses": null,
      "windowDays": null,
      "dailyLimit": null,
      "validFrom": "2026-10-13T06:00:00.000Z",
      "externalRef": "TPASS-2210",
      "relation": "issued",
      "renewable": true
    }
  ]
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 credential_not_found — No Ta'ab card has that UID, or the QR is not one Ta'ab issued.
  • 409 card_not_assigned — A real Ta'ab card, but in stock, returned, lost or retired.
  • 409 credential_stale — The QR token has been superseded by a newer one. Ask the holder to refresh and scan again.
  • 409 credential_revoked — The QR credential was revoked (a lost phone).
  • 409 account_inactive — The account is suspended, closed or scrubbed.
  • 409 account_unlinked — The account has no login and no card, so nobody could present a pass on it.
POST /passes/redeem

Redeem a pass

Consumes one use of a pass for whoever is presenting the credential. Name the pass with externalRef, or leave it out and Ta'ab uses the holder's best pass of that type that your institution accepts. A denial is not an error: it is HTTP 200 with result: denied_…, and it is recorded in the holder's history. Reusing an idempotencyKey returns the original decision and consumes nothing more, so retry freely on a timeout.

Scope passes.redeem

Request body

FieldTypeNotes
credentialobjectWhat the holder presented: a card tap or the Ta'ab app's QR. Send the raw value, never a hash.
credential.type"nfc" | "qr"Which kind was presented.
credential.uidstringRequired when type is "nfc". The card's UID exactly as the reader reports it. Any spelling is accepted: 04A22B91, 04:a2:2b:91, 04 A2 2B 91.
credential.tokenstringRequired when type is "qr". The string decoded from the QR in the Ta'ab app. It rotates every 60 seconds; with no signal the app shows an offline code (o1.…) instead, good for 10 minutes, which Ta'ab takes too.
externalRefstringoptionalThe pass to redeem, by your own reference. Leave it out to redeem whatever pass of `type` this holder has that your institution accepts, such as a T-Pass; a ticket (a pass you issued spelled out, not from a product) is only ever redeemed by its reference.
type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"optionalOnly without externalRef: the kind of pass to look for. Defaults to transit.
operatorRefstringoptionalYour own name or id for the operator doing the accepting, when you front several (a bus company). Recorded with the redemption and returned in GET /redemptions; Ta'ab's usage report groups by it.
idempotencyKeystringOne key per real-world redemption. Reusing it returns the original decision and consumes nothing more.
occurredAttimestampWhen the tap happened, by your device's clock (ISO 8601).
offlinebooleanoptionaltrue when your device decided this ride with no signal (from GET /passes/offline) and is uploading it now. The ride stands: Ta'ab records it as approved and says in offlineDenial what it would have answered at the time. occurredAt must be within the last 72 hours.

Example

curl -X POST "https://sandbox.taab.mohbel.com/api/partner/v1/passes/redeem" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"credential":{"type":"nfc","uid":"04A22B9151C280"},"externalRef":"TKT-8842-1","idempotencyKey":"ticket-55120-checkin","occurredAt":"2026-10-14T13:22:05Z"}'

Answer 200

The decision: approved, or the reason for the denial.

FieldTypeNotes
result"approved" | "denied_expired" | "denied_revoked" | "denied_insufficient_balance" | "denied_no_uses_remaining" | "denied_unknown_credential" | "denied_suspended" | "denied_wrong_institution" | "denied_not_holder" | "denied_recently_used" | "denied_daily_limit"approved, or the reason for the denial. A denial is still HTTP 200.
reasonstring | nullThe denial in words, for a screen. null when approved.
externalRefstring | nullThe reference you sent. null only when you sent none.
passIdstring | nullThe pass the decision was about. null only when you sent no externalRef and the holder has no pass of that type at all.
remainingUsesinteger | nullUses left on the pass after this call.
taabIdstringThe presenter's Ta'ab ID. On denied_not_holder this is not the pass's owner.
occurredAttimestamp
replayedbooleantrue when this idempotencyKey was seen before and the original decision is returned.
offlinebooleantrue when you sent offline: true. The ride was decided on your device and stands.
offlineDenial"approved" | "denied_expired" | "denied_revoked" | "denied_insufficient_balance" | "denied_no_uses_remaining" | "denied_unknown_credential" | "denied_suspended" | "denied_wrong_institution" | "denied_not_holder" | "denied_recently_used" | "denied_daily_limit" | nullOnly on an offline redemption: what Ta'ab would have answered at the time, had it been asked (the result is still approved). null when it would have approved too.
{
  "result": "approved",
  "reason": null,
  "externalRef": "TKT-8842-1",
  "passId": "0b9c2f6e-6f0e-4c0f-9a6e-2f1f0b7c9d11",
  "remainingUses": 0,
  "taabId": "TB-00000001234",
  "occurredAt": "2026-10-14T13:22:05.000Z",
  "replayed": false,
  "offline": false,
  "offlineDenial": null
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 pass_not_found — You have no pass with that externalRef.
  • 404 credential_not_found — No Ta'ab card has that UID, or the QR is not one Ta'ab issued.
POST /charges

Charge a holder's balance

Takes amountCents from the balance the presenting holder keeps with your institution (a school canteen's), when they present their card or QR. The card is the consent. Before Ta'ab's payment licence, that balance is the only one you can charge, and it is spent only at your institution. A decline is not an error: it is HTTP 200 with result: declined and the reason, and nothing is taken. Reusing an idempotencyKey returns the original answer and takes nothing more.

Scope wallet.charge

Request body

FieldTypeNotes
credentialobjectWhat the holder presented: a card tap or the Ta'ab app's QR. Send the raw value, never a hash.
credential.type"nfc" | "qr"Which kind was presented.
credential.uidstringRequired when type is "nfc". The card's UID exactly as the reader reports it. Any spelling is accepted: 04A22B91, 04:a2:2b:91, 04 A2 2B 91.
credential.tokenstringRequired when type is "qr". The string decoded from the QR in the Ta'ab app. It rotates every 60 seconds; with no signal the app shows an offline code (o1.…) instead, good for 10 minutes, which Ta'ab takes too.
amountCentsintegerWhat to take, in BZD cents.
idempotencyKeystringOne key per real-world charge. Reusing it returns the original answer and takes nothing more.
reasonstringoptionalWhat it was for, as the holder will read it on their statement ("Lunch").

Example

curl -X POST "https://sandbox.taab.mohbel.com/api/partner/v1/charges" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"credential":{"type":"nfc","uid":"04A22B9151C280"},"amountCents":350,"idempotencyKey":"till-3-20261027-0042","reason":"Lunch"}'

Answer 200

Approved with the balance left, or declined with the reason.

FieldTypeNotes
result"approved" | "declined"A decline is still HTTP 200: render it.
reason"insufficient_balance" | "over_daily_limit" | "wrong_organisation" | "purse_frozen" | "account_inactive" | "credential_revoked" | "licence_required" | "not_merchant" | nullWhy it was declined. null when approved. insufficient_balance also covers a holder with no balance kept by your organisation.
taabIdstringThe presenter's Ta'ab ID.
amountCentsinteger
balanceAfterCentsinteger | nullThe holder's balance with your organisation after the charge. null when declined.
chargeIdstring | nullTa'ab's id for the charge. null when declined.
replayedbooleantrue when this idempotencyKey was seen before and the original answer is returned.
{
  "result": "approved",
  "reason": null,
  "taabId": "TB-00000001234",
  "amountCents": 350,
  "balanceAfterCents": 1650,
  "chargeId": "9b2f6c1e-5d0a-4a52-9c1b-2f7a3e1d8c40",
  "replayed": false
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 credential_not_found — No Ta'ab card has that UID, or the QR is not one Ta'ab issued.
  • 409 credential_stale — The QR token has been superseded by a newer one. Ask the holder to refresh and scan again.
GET /redemptions

List your redemptions

Every redemption you made, approvals and denials, oldest first. To follow new ones, keep the last nextCursor and ask again with it.

Scope passes.redeem read-only: writes nothing

Query

FieldTypeNotes
sincetimestampoptionalOnly redemptions recorded at or after this time (ISO 8601).
limitintegeroptionalHow many to return, 1 to 200. Defaults to 50.
cursorstringoptionalThe nextCursor from the previous page.

Example

curl "https://sandbox.taab.mohbel.com/api/partner/v1/redemptions?limit=50" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET"

Answer 200

A page of redemptions.

FieldTypeNotes
redemptionsarray of objectOldest first.
redemptions[].idstring
redemptions[].result"approved" | "denied_expired" | "denied_revoked" | "denied_insufficient_balance" | "denied_no_uses_remaining" | "denied_unknown_credential" | "denied_suspended" | "denied_wrong_institution" | "denied_not_holder" | "denied_recently_used" | "denied_daily_limit"
redemptions[].externalRefstring | null
redemptions[].passIdstring | null
redemptions[].taabIdstringWho presented.
redemptions[].occurredAttimestampWhen the tap happened, by your clock.
redemptions[].recordedAttimestampWhen Ta'ab recorded it.
redemptions[].idempotencyKeystring | null
redemptions[].operatorRefstring | nullThe operatorRef you sent with it, or null.
redemptions[].offlinebooleanDecided on your device with no signal and uploaded later.
redemptions[].offlineDenial"approved" | "denied_expired" | "denied_revoked" | "denied_insufficient_balance" | "denied_no_uses_remaining" | "denied_unknown_credential" | "denied_suspended" | "denied_wrong_institution" | "denied_not_holder" | "denied_recently_used" | "denied_daily_limit" | nullFor an offline redemption, what Ta'ab would have answered at the time; null otherwise.
nextCursorstring | nullWhere to resume from. Returned even when you are caught up, so you can poll with it.
hasMorebooleantrue when another page is ready now.
{
  "redemptions": [
    {
      "id": "5d16c08d-f833-4518-a93d-3723d75aa103",
      "result": "approved",
      "externalRef": "TKT-8842-1",
      "passId": "0b9c2f6e-6f0e-4c0f-9a6e-2f1f0b7c9d11",
      "taabId": "TB-00000001234",
      "occurredAt": "2026-10-14T13:22:05.000Z",
      "recordedAt": "2026-10-14T13:22:06.412Z",
      "idempotencyKey": "ticket-55120-checkin",
      "operatorRef": null,
      "offline": false,
      "offlineDenial": null
    }
  ],
  "nextCursor": "MjAyNi0xMC0xNFQxMzoyMjowNi40MTJafDVkMTZjMDhk",
  "hasMore": false
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
GET /offline/passes

Who may board with no signal

For a scanner that loses signal: every holder with a pass your institution honours at some point in the next 24 hours, with the hashes of their cards' UIDs and their Ta'ab ID (what an offline QR code carries). Fetch it while online, a page at a time, and refresh it often. With no signal, accept a card whose UID hash is listed, or an offline QR code whose signature checks out (GET /offline/key) and whose Ta'ab ID is listed; apply your own rule against a second tap. Then upload each ride with passes/redeem and offline: true.

Scope passes.redeem read-only: writes nothing

Query

FieldTypeNotes
type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"optionalThe kind of pass to list. Defaults to transit.
cursorstringoptionalThe nextCursor from the previous page.

Example

curl "https://sandbox.taab.mohbel.com/api/partner/v1/offline/passes?limit=50" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET"

Answer 200

A page of the list.

FieldTypeNotes
generatedAttimestamp
honourUntiltimestampAccept from this list until then, 24 hours on. Past it, take no offline rides: fetch a new list first.
holdersarray of objectA page of holders, by no particular order.
holders[].taabIdstringWhat an offline QR code carries.
holders[].cardUidHashesarray of stringsha256, in lowercase hex, of each of the holder's cards' UIDs, normalised: hex digits only, uppercase (04A22B9151C280). Hash the UID you read the same way and look it up.
holders[].passesarray of objectThe holder's passes your institution honours within the list's window.
holders[].passes[].passIdstring
holders[].passes[].namestring | nullThe product's name, for your screen.
holders[].passes[].validFromtimestamp
holders[].passes[].validUntiltimestampFor a pass that starts on first use and has not, the deadline for that first use.
holders[].passes[].remainingUsesinteger | nullUses left when the list was made; null for unlimited.
holders[].passes[].windowDaysinteger | null
nextCursorstring | nullSend as cursor for the next page. null on the last page.
{
  "generatedAt": "2026-10-16T12:00:00.000Z",
  "honourUntil": "2026-10-17T12:00:00.000Z",
  "holders": [
    {
      "taabId": "TB-00000001234",
      "cardUidHashes": [
        "5f2b0c6e9d1a4b7c8e3f2a1d0c9b8a7e6f5d4c3b2a1908f7e6d5c4b3a2918070"
      ],
      "passes": [
        {
          "passId": "7c1d4e9a-2b3f-4c5d-8e6f-0a1b2c3d4e5f",
          "name": "T-Pass 30 days",
          "validFrom": "2026-10-13T06:00:00.000Z",
          "validUntil": "2026-11-12T06:00:00.000Z",
          "remainingUses": null,
          "windowDays": null
        }
      ]
    }
  ],
  "nextCursor": null
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
GET /offline/key

The key that signs offline QR codes

With no signal the Ta'ab app shows an offline code instead of its rotating QR: o1.{kid}.{taabId}.{window}.{signature}. The signature is Ed25519 over everything before the last dot, and window is the 10-minute slot since the Unix epoch (floor(unix seconds / 600)) the code is good for; accept the slot either side of your clock's. Online, send the code to Ta'ab as a qr credential like any other.

Scope passes.redeem read-only: writes nothing

Example

curl "https://sandbox.taab.mohbel.com/api/partner/v1/offline/key" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET"

Answer 200

The public key.

FieldTypeNotes
kidstringThe key's id; an offline code carries it as its second field.
algorithm"Ed25519"
publicKeystringThe raw 32-byte Ed25519 public key, base64url.
windowSecondsintegerHow long one offline code is good for.
{
  "kid": "3fa9c2d1",
  "algorithm": "Ed25519",
  "publicKey": "11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo",
  "windowSeconds": 600
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 offline_unavailable — Offline codes are not set up on this Ta'ab, so there is no key to check them with.
GET /products

List your catalog

The active products of your institution. Pass a productId to POST /passes to issue from one.

Scope passes.issue read-only: writes nothing

Example

curl "https://sandbox.taab.mohbel.com/api/partner/v1/products" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET"

Answer 200

Your catalog.

FieldTypeNotes
productsarray of object
products[].productIdstring
products[].namestring
products[].type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"
products[].tierstring | null
products[].priceCentsintegerThe price shown at the counter, in BZD cents. Ta'ab does not charge it.
products[].durationDaysintegerHow long a pass issued from this product is valid.
products[].startsOn"issue" | "first_use"When those days start counting: issue (the day the pass is issued) or first_use (its first redemption; until then it must be used within a year).
products[].usesinteger | nullUses on the pass. null means unlimited within its window.
products[].dailyLimitinteger | nullUses a day on a pass issued from it, the day being your institution's. null means no limit.
products[].concessionsarray of "student" | "senior" | "staff" | nullWho it may be issued to: a holder whose roster places give them one of these concession profiles. null means anyone. Issuing it to anyone else answers concession_required.
{
  "products": [
    {
      "productId": "3f1c7a52-0d7e-4a51-9a3b-6f0b2f7d8e21",
      "name": "Monthly pass",
      "type": "general",
      "tier": null,
      "priceCents": 4500,
      "durationDays": 30,
      "startsOn": "issue",
      "uses": null,
      "dailyLimit": null,
      "concessions": null
    }
  ]
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
POST /cards/activate

Activate a card for a walk-in rider

Turns one of your institution's stock cards into a working Ta'ab card for a rider with no Ta'ab login, and perhaps no phone. You get the card's Ta'ab ID, to issue passes to as usual, and a claim code to print on the rider's slip: entered in the Ta'ab app, it puts the card and its passes on their own account. Activating a card that is already yours answers again with its Ta'ab ID and no code. A card lost before anyone claims it is gone, with its passes.

Scope cards.activate

Request body

FieldTypeNotes
uidstringThe card's UID exactly as the reader reports it. Any spelling is accepted: 04A22B91, 04:a2:2b:91, 04 A2 2B 91.
newClaimCodebooleanoptionaltrue to replace the claim code of a card you already activated and nobody has claimed: for when the first answer never reached you. The old code stops working.

Example

curl -X POST "https://sandbox.taab.mohbel.com/api/partner/v1/cards/activate" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET" \
  -H "Content-Type: application/json" \
  -d '{"uid":"04:a2:2b:91:51:c2:80"}'

Answer 201

The card works now. 200 with replayed: true, and claimCode null unless you sent newClaimCode, when it was already activated.

FieldTypeNotes
taabIdstringThe card's account. Issue passes to it like any other, then the rider boards by tapping the card.
cardNumberstringThe number printed on the card.
claimCodestring | nullPrint it on the rider's slip: entered in the Ta'ab app or at /activate, it puts the card and its passes on their own account. Shown only in this answer; null when the card was already activated (unless you asked for newClaimCode).
replayedbooleantrue when the card was already activated: by this call earlier, or by someone else at your institution.
{
  "taabId": "TB-00000005521",
  "cardNumber": "C-0107",
  "claimCode": "TAAB-7K3QM-2XAP9",
  "replayed": false
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 credential_not_found — No Ta'ab card has that UID, or the QR is not one Ta'ab issued.
  • 409 conflict — The externalRef is already used for a different account, the product is archived, or a renewal's days have begun so it can't be revoked.
GET /accounts/{taabId}/passes

Read the passes a linked holder holds

For a holder linked to you (POST /links) or on your institution's list: the live passes your institution issued or honours, whoever sold them. This is what a page showing someone their T-Pass reads. relation says whose each pass is, and renewable whether selling its product again renews it. Never a pass you don't honour.

Scope passes.issueor passes.redeem read-only: writes nothing

Path

  • taabId — The holder's Ta'ab ID, from POST /links or GET /links/{linkId}.

Example

curl "https://sandbox.taab.mohbel.com/api/partner/v1/accounts/{taabId}/passes" \
  -u "$TAAB_CLIENT_ID:$TAAB_CLIENT_SECRET"

Answer 200

The holder's passes that are good with you.

FieldTypeNotes
taabIdstring
passesarray of objectLive passes your institution issued or honours, whoever sold them: those usable now first, in the order Ta'ab would spend them, then those that start later. Never another institution's pass you don't honour.
passes[].passIdstringTa'ab's id for the pass.
passes[].type"transit" | "meal" | "library" | "attendance" | "event" | "staff" | "visitor" | "general"
passes[].productIdstring | nullThe catalog product it was issued from, or null.
passes[].namestringWhat to call it on a screen: the product's name, or the kind of pass.
passes[].validFromtimestampWhen it starts being valid.
passes[].validUntiltimestampWhen it stops being valid. For a pass that starts on first use and has not been used, the deadline for that first use (see windowDays).
passes[].remainingUsesinteger | nullUses left. null means unlimited within the window.
passes[].windowDaysinteger | nullSet only while a pass that starts on first use has not been used: how many days it will run from its first redemption. null otherwise.
passes[].dailyLimitinteger | nullHow many times a day the pass can be used, the day being its institution's; past it a redemption answers denied_daily_limit. null means no limit.
passes[].externalRefstring | nullYour reference for it, when you issued it; null for a pass someone else issued.
passes[].relation"issued" | "accepted"issued: your institution's pass, whoever sold it. accepted: another institution's pass that yours honours.
passes[].renewablebooleantrue when selling its product again (POST /passes with its productId) renews this pass: you sold it, its product counts no uses, and it has started. false for a pass of that product you can't renew: selling it would answer already_held.
{
  "taabId": "TB-00000001234",
  "passes": [
    {
      "passId": "7c1d4e9a-2b3f-4c5d-8e6f-0a1b2c3d4e5f",
      "type": "transit",
      "productId": "9a8b7c6d-5e4f-4a3b-8c2d-1e0f9a8b7c6d",
      "name": "T-Pass 30 days",
      "validUntil": "2026-11-12T06:00:00.000Z",
      "remainingUses": null,
      "windowDays": null,
      "dailyLimit": null,
      "validFrom": "2026-10-13T06:00:00.000Z",
      "externalRef": "TPASS-2210",
      "relation": "issued",
      "renewable": true
    }
  ]
}

Errors

  • 400 invalid_input — The body, query or path is malformed, or a uid isn't a card UID.
  • 401 unauthorized — Missing, wrong or disabled client credentials.
  • 403 forbidden — Valid client, but it lacks the scope this route needs.
  • 429 rate_limited — More than 600 requests in a minute. Wait for the seconds in the Retry-After header.
  • 404 account_not_found — No account has that Ta'ab ID.
  • 409 not_connected — The holder hasn't linked their account to you (POST /links) and isn't on your institution's list, so their passes aren't yours to read.
  • 409 account_inactive — The account is suspended, closed or scrubbed.

Terminal API guide

How a scan device redeems passes: a bus reader, a cafeteria till, a library gate, an event scanner. One endpoint, POST /api/scan. The device reads a card or a QR, says what kind of event this is, and Ta'ab answers approved or denied.

If you are a backend with your own tickets or memberships, use the partner API instead. This API is for hardware at a place: it always records an event, and it picks the holder's pass for you.

Authentication

A Ta'ab admin provisions each device and hands over its id and secret. The secret is shown once; only its hash is stored.

Authorization: Basic base64(deviceId:secret)
Content-Type: application/json

A device belongs to one institution, or to none (a general-public device). It has a type, which decides the events it may send:

Device type Events it may send
transit_reader transit_ride
meal_pos meal_swipe
library_gate library_checkin, library_checkout
attendance_scanner attendance_checkin
event_gate event_checkin, staff_checkin, visitor_checkin

A disabled device, an unknown id and a wrong secret all answer the same 401.

Request

{
  "qrToken": "04:a2:2b:91:51:c2:80",
  "eventType": "transit_ride",
  "idempotencyKey": "bus12-000481",
  "occurredAt": "2026-10-14T13:22:05Z"
}
Field Notes
qrToken What the device read: the QR's text, or a card's UID in any spelling. Send it raw
eventType One of the events this device's type may send
idempotencyKey Unique per physical scan. A local counter is fine
occurredAt When the scan happened, by the device's clock
amountCents Required for meal_swipe, and only then: the price rung up, in BZD cents, at most 10000
metadata Optional, free-form: a route, a stop, a till number

Response

{
  "result": "approved",
  "eventType": "transit_ride",
  "taabId": "TB-00000001234",
  "studentNumber": "2026-0147",
  "occurredAt": "2026-10-14T13:22:05.000Z",
  "balanceAfterCents": null,
  "reason": null,
  "replayed": false
}
Field Notes
result approved, or the reason for the denial (below)
taabId The holder's permanent Ta'ab ID
studentNumber Their number at their institution, if they have one
balanceAfterCents The wallet balance after an approved meal_swipe; otherwise null
reason The denial in words, for a screen. null when approved
replayed true when this idempotencyKey was seen before and the original answer is returned

How Ta'ab decides

  1. The credential must be one Ta'ab issued. If it matches nothing at all, the answer is 404.
  2. The credential must be current and the account in good standing.
  3. Ta'ab looks at every pass the holder has of the kind the event needs: a transit pass for transit_ride, a meal plan for meal_swipe, and so on.
  4. A pass can be used if it is valid at occurredAt, honoured at this device's institution, and has a use left. A pass is honoured at the institution it belongs to, and at any institution on its product's acceptance list.
  5. Ta'ab spends something from the best of those: one use of a counted pass, or amountCents from the wallet for a meal_swipe. A time-window pass spends nothing, so it is used before a counted one; between two alike, the one that lapses sooner.
  6. An unlimited transit pass carries one rider: a second ride on it at this institution within 10 minutes of the last is refused (denied_recently_used), and nothing else is spent in its place.
  7. A pass with a daily limit (a meal plan of 3 a day) is refused once that many uses have been approved on it that day, the day being its institution's. Ta'ab then tries the holder's next pass; if none will do, the answer is denied_daily_limit. A meal_swipe paid from the wallet is not limited.

A holder with several passes of one kind needs nothing from the device: a school pass is used at the school's reader and a city-wide pass on the bus. A pass whose days start on first use starts counting at its first approved scan.

Tickets issued through the partner API (a bus ticket, for example) are never picked here. They are redeemed by the organisation that issued them. A pass an organisation sold from its catalog, such as a T-Pass bought through ticketing, is picked like any other.

Denials are answers, not errors

Status Meaning
200 The request was understood. result says approved or denied
400 The body is malformed, or this device's type can't send that event
401 Missing or wrong device credentials, or the device is disabled
404 The credential matches nothing Ta'ab issued. Nothing is recorded
409 The wallet was too busy to debit. Retry with the same idempotencyKey
429 More than 300 requests in a minute. Wait for the seconds in Retry-After
Result Meaning
approved Let them through
denied_expired No pass of that kind, or the one this institution honours is outside its validity window
denied_revoked The pass or the credential was revoked (a lost card)
denied_insufficient_balance Not enough in the wallet for this meal_swipe
denied_no_uses_remaining The pass's uses are spent
denied_unknown_credential A QR that has since rotated. Scan again
denied_suspended The account is not in good standing
denied_wrong_institution The holder has a valid pass of that kind, but none this institution honours
denied_recently_used An unlimited transit pass already used at this institution in the last 10 minutes: one card is carrying a second rider
denied_daily_limit The pass's uses for today are spent (a meal plan of 3 a day, at its fourth meal). It works again after the institution's midnight

Every 200, approved or denied, is recorded in the holder's history.

Retries and offline readers

Retry any failed or timed-out request with the same idempotencyKey. Ta'ab returns the original answer and spends nothing twice, even if the holder's QR has rotated in between.

A reader with no signal can store its scans and upload them later, each with its own idempotencyKey and the occurredAt of the real scan. Validity is judged at occurredAt.

Checklist

  1. Have the device provisioned with the right type, and store its id and secret in its own configuration.
  2. Send Basic auth on every call. No user login or session is involved.
  3. Generate a real per-scan idempotencyKey.
  4. Handle both branches: an HTTP error means the request was wrong; a 200 with result: "denied_…" means the request was fine and the answer is no.