Webhooks

Webhooks push purchase, buyback, redemption and payout events to your backend so you don't have to poll. Manage them with the register, list, delete and delivery log endpoints (scope webhooks:manage).

Registration

POST /api/v1/webhooks with { url, event_types? }. Omit event_types to subscribe to everything. Up to 20 webhooks per partner. Private or internal URLs are rejected.

The response includes the HMAC signing_secret, a 64-hex string that is shown exactly once, at creation. Store it immediately, because GET /webhooks never returns secrets.

Delivery

Each event is delivered as a POST to your URL with these headers:

X-Mystery-Event: purchase.fulfilled
X-Mystery-Delivery: <event_id>            # idempotency key, dedupe on this
X-Mystery-Timestamp: <unix_ms>
X-Mystery-Signature: t=<unix_ms>,sha256=<hex>

And this body shape:

{ "event": "purchase.fulfilled", "id": "purchase.fulfilled:5012",
  "data": { "purchase_id": 5012, "status": "FULFILLED", "onchain_request_id": "0xabc…" } }

Verifying signatures

  1. Compute HMAC_SHA256(signing_secret, "<timestamp>.<raw_body>") using the raw request body.
  2. Compare it (constant-time) against the sha256= value in X-Mystery-Signature.
  3. Reject deliveries whose timestamp is older than ~5 minutes (replay protection).
  4. Dedupe on X-Mystery-Delivery, since retries re-send the same id.

Retries

Any non-2xx response (or timeout) is retried up to 6 attempts with exponential backoff. Use the delivery log to debug a consumer that is not receiving events.

Event types

EventFires when
deposit.creditedAn on-chain deposit was confirmed and credited to an end-user's custodial wallet
purchase.reservedA custodial purchase reserved the funds from the wallet balance (202 accepted)
purchase.submittedOn-chain purchase broadcast
purchase.fulfilledThe random reveal finished and the purchase is complete — payload includes the hydrated items[] when available (see below)
purchase.refundedPurchase refunded (e.g. machine empty)
purchase.failedPre-broadcast failure
instant_purchase.reservedA custodial instant purchase reserved the funds
instant_purchase.submittedThe buy-and-open transaction was broadcast on-chain
instant_purchase.fulfilledThe VRF settled and the cards were delivered — the payload is the whole serialized purchase, pack and cards included (see below)
instant_purchase.refundedThe on-chain purchase was refunded
instant_purchase.failedPre-broadcast failure
buyback.confirmedA holder accepted a pool-model buyback offer and the OfferAccepted event settled (the fronted USDC lands on your bill)
buyback.transfer_heldThe bought-back card reached the pool signer and is held pending forwarding
buyback.card_transferredThe bought-back card was forwarded to your pool wallet
buyback.transfer_failedForwarding the bought-back card failed — the card is held; contact support
pool.item_pulledA pull delivered an item from YOUR pool inventory (pool model — the item lands on your bill)
sellback.confirmedA holder sold cards back to the pool. Includes the tokens, the USDC they were paid and the tx hash — this amount is the deduction in your net revenue
redemption.preparedRedemption prepared and the transaction to send is ready (emitted inline by prepare)
redemption.updatedEvery redemption status transition thereafter (burn, fulfillment, completion, failure)
payout.statement_readyA revenue-share period closed. Carries the frozen statement: gross, sell backs, net, your share
payout.paidYour share was paid out. Includes the amount and the on-chain tx hash

The buyback and payout payloads carry everything you need to reconcile state without a second call. sellback.confirmed includes the token_ids, the total_usdc the pool paid and the transaction_hash; the two payout.* events carry the whole frozen statement, so your ledger can mirror ours from the webhook alone.

purchase.fulfilled also carries the hydrated items[] array — the same shape GET /purchase/:id returns, including the canonical card_id — so a reveal needs no follow-up call at all. Hydration is best-effort and the key is omitted entirely rather than sent empty when it fails, so treat a delivery with no items as "fetch the purchase by purchase_id", not as a pull that yielded nothing.

instant_purchase.fulfilled goes further: its data is the entire serialized instant purchase, the same object GET /instant/purchase/:id returns, with the delivered pack and its cards. If hydration fails it degrades to the core purchase fields, and in the worst case to just id, purchase_id and status. Read the keys you need defensively and fall back to the purchase endpoint; only those three are guaranteed.