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.
| scope | endpoints |
|---|---|
voucher.read | GET /partner/vouchers/{codeOrTicket}, GET …/events |
voucher.redeem | POST /partner/vouchers/reserve, /redeem, /release |
GET /partner/vouchers/{codeOrTicket}
Section titled “GET /partner/vouchers/{codeOrTicket}”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.
status reflects expiry live — a voucher past validUntil reads Expired whether or not
a background job has written that down yet.
POST /partner/vouchers/reserve
Section titled “POST /partner/vouchers/reserve”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.
POST /partner/vouchers/redeem
Section titled “POST /partner/vouchers/redeem”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" }.
| field | required | notes |
|---|---|---|
code / ticket | one of them | a ticket from /reserve, or the voucher’s code |
externalRef | yes | your order reference — the idempotency key |
note | no | free 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.
POST /partner/vouchers/release
Section titled “POST /partner/vouchers/release”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.
Errors
Section titled “Errors”| status | when |
|---|---|
| 400 | a required field is missing — the body says which |
| 401 | no key, unknown key, or the key lacks the scope |
| 403 | caller IP not in the key’s allowlist, or the key is not bound to an org |
| 404 | no voucher of yours matches |
| 409 | the 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.