Skip to content

Partner API

Base URL: https://api.bullmark.net

Authentication: X-Api-Key: bm_live_… on every request. Your key is bound to an Axowl org, and that binding is what scopes it to your brands — a voucher belonging to anyone else answers 404, never 403, because “wrong brand” would confirm that a guessed code is real.

scopeendpoints
voucher.readGET /partner/vouchers?holder=…, GET /partner/vouchers/{codeOrTicket}, GET …/events
voucher.redeemPOST /partner/vouchers/claim, /reserve, /redeem, /release

Which of your vouchers does this customer hold? For showing a signed-in customer what they can spend with you, without making them dig up a code.

holder is the end-user id from the customer’s token sub — the same guid Bullmark’s own /me/vouchers answers for. It may repeat (?holder=a&holder=b, up to 8): one person can be more than one end-user row on your org, e.g. an email login and an SNS login. At least one holder is required — without it the call answers 400, never “everything”.

{
"vouchers": [{
"id": "…", "code": "BM-ACME-A8F2-K9R4", "serial": 37, "brand": "acme",
"productName": "Pro", "productKey": "plan.pro", "type": "Subscription",
"retailPriceUsd": 25, "priceUsd": 20, "benefitMonths": 1,
"usageUnitLabel": null, "issuedAt": "2026-08-01T00:00:00Z",
"validUntil": "2027-05-01T00:00:00Z",
"status": "Active", "redeemedAt": null,
"pendingTransfer": false,
"verifyUrl": "https://api.bullmark.net/integrity/events/VoucherInstance/…"
}]
}

Newest first. Used and expired vouchers stay in the list with their status flipped; vouchers handed on to someone else do not appear. A brand owner’s own unsold supply is stock, not held vouchers, and is excluded the same way.

pendingTransfer: true means a live hand-over link is out on that voucher — it cannot be spent until the link is taken or lapses. Show it as locked, or your customer walks into a refusal.


What is this worth? Changes nothing.

{
"voucher": {
"id": "…", "code": "BM-ACME-A8F2-K9R4", "serial": 37, "brand": "acme",
"productName": "Pro", "productKey": "plan.pro", "type": "Subscription",
"retailPriceUsd": 25, "priceUsd": 20, "benefitMonths": 1,
"usageUnitLabel": null, "validUntil": "2027-05-01T00:00:00Z",
"status": "Active", "redeemedAt": null
},
"reservation": null,
"verifyUrl": "https://api.bullmark.net/integrity/events/VoucherInstance/…"
}

benefitMonths: 0 means the listing stated no term. It does not mean one month.

A voucher from a referrer-only batch inspects and redeems through this API exactly like any other — the audience rule gates who may take one from an open window, not who may honour one. By the time a code reaches your checkout, someone already holds it.

status reflects expiry live — a voucher past validUntil reads Expired whether or not a background job has written that down yet.


Your signed-in customer activated a deal — hand them their voucher. Same mechanics as a person claiming through /r/{code} themselves: attribution to the referral contract, live stock, one voucher per person per product, sealed hand-over.

{ "code": "BM-XXXX", "productId": null, "holder": "…", "holderEmail": "[email protected]" }
fieldrequirednotes
codeyesthe referral code of the deal campaign — not a voucher code
holderyesthe end-user the voucher is claimed for (same guid as the held list’s holder)
productIdwhen ambiguousomit when the campaign offers exactly one product
holderEmailnorecorded on the voucher for the human-readable trail

201 — { "claimed": true, "voucher": { … }, "verifyUrl": "…" }

409 already_claimed carries the voucher they already hold. Read it as done, not failed — it is what makes a retry after a timeout safe, and what enforces one per person. Other refusals say why: code_inactive, referral_exhausted, brand_out_of_stock, own_code, own_brand.

Only campaigns of your brands answer; anyone else’s code is a 404.


Hold it for one checkout (15 minutes).

{ "code": "BM-ACME-A8F2-K9R4" }
{ "ticket": "…", "token": "…", "expiresAt": "…", "voucher": "…" }

token is a secret — it confirms the burn. Asking again while a reservation is live returns the same ticket rather than a second one.


The call. You gave the value; the voucher burns.

{ "code": "BM-ACME-A8F2-K9R4", "externalRef": "INV-2291", "note": "counter 2" }

or { "ticket": "…", "externalRef": "INV-2291" }.

fieldrequirednotes
code / ticketone of thema ticket from /reserve, or the voucher’s code
externalRefyesyour order reference — the idempotency key
notenofree text, recorded on the sealed event
{
"redeemed": true, "alreadyConfirmed": false,
"voucherInstanceId": "…", "redeemCode": "…", "serial": 37,
"productName": "Pro", "productKey": "plan.pro",
"discountPriceUsd": 20, "benefitMonths": 1,
"redeemedAt": "2026-08-29T10:00:00Z",
"verifyUrl": "https://api.bullmark.net/integrity/events/VoucherInstance/…"
}

Retrying with the same externalRef returns alreadyConfirmed: true and burns nothing further. It is a success. Sending a different reference for the same order is what costs your customer a second voucher.


Give it back unspent.

{ "ticket": "…", "reason": "payment_failed" }
{ "released": true }

{ "released": false, "reason": "no live reservation" } when there was nothing to release — not an error, so this is safe in a finally.


GET /partner/vouchers/{codeOrTicket}/events

Section titled “GET /partner/vouchers/{codeOrTicket}/events”

The sealed chain with canonical payloads, so you can recompute the hashes yourself. The public /integrity/events/… endpoint withholds payloads (it is open to anyone with a guid, and payloads carry redeem codes); your own key gets them for your own vouchers.

{
"entityType": "VoucherInstance", "entityId": "…",
"anchorNamespace": "VoucherInstance:events",
"events": [{
"sequence": 1, "eventType": "voucher.instance.claimed", "code": "VCH010",
"occurredAt": "…", "sealStatus": "Sealed",
"recordHash": "…", "previousHash": "000…0",
"payload": "{…}", "actorConnectedId": null, "actorIsEmployee": null
}],
"canonPreservedSinceUtc": "2026-08-02T11:00:00Z"
}

Never re-serialize payload. It is the exact string that was hashed; re-encoding it is how its canonical form gets lost.


statuswhen
400a required field is missing — the body says which
401no key, unknown key, or the key lacks the scope
403caller IP not in the key’s allowlist, or the key is not bound to an org
404no voucher of yours matches
409the voucher cannot move — already used, expired, voided, or reserved by someone else

Every error body is { "error": "…" } in plain words. Show it; it is written for a person.