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/{codeOrTicket}, GET …/events
voucher.redeemPOST /partner/vouchers/reserve, /redeem, /release

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.


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.