This page shows how to receive webhooks and handle them correctly. You need a public HTTPS URL on your server.
A webhook is an HTTP request that Cardzone sends to your server when an order changes. With webhooks, you don’t have to poll on a timer.
Polling is still the reliable way to get an order’s state. An integration that only polls is correct. Webhooks make it faster.
Where Cardzone sends webhooks
Live orders send webhooks to both of these targets, if you set them:
- Your store webhook endpoint. Set it in the Cardzone dashboard (for stores managed in Cardzone). It receives webhooks for every order your API keys create. It can also receive other event types.
- A
webhook_urlfor one order. Send it when you create the order. It must be anhttps://URL. Its host must not belocalhostor a private or reserved IP address. Otherwise create order returns422 validation_failed, withdetailsset tohttps_required,host_required,private_host, orinvalid_url.
Sandbox API keys never send webhooks. Nothing is sent while your store is inactive.
Events
Each webhook is one event. event_id is the ID of that event.
| Event | When | event_id |
|---|---|---|
b2b.order.created |
The API accepted an order. This is not a success signal. | b2b.order.created:<order_id> |
b2b.order.status_changed |
The public status changed, or Cardzone delivered replacement codes that your store paid for. |
b2b.order.status_changed:<order_id>:<sequence> |
Your store endpoint can also receive events for orders from other Cardzone channels. Those events have a different shape. So:
- Check
event(or theX-Cardzone-Eventheader) to see which event it is. - For any event you don’t handle, answer
200and do nothing else. Then Cardzone won’t retry it.
Both B2B events use the same outer structure (the envelope). The order inside is the same as the Poll order status response:
{
"event": "b2b.order.status_changed",
"event_id": "b2b.order.status_changed:1CBDzqwiovwVrtFk6nZ1Aw:2",
"platform": "cardzone",
"source": "b2b_api",
"created_at": "2026-06-30T09:00:41Z",
"store": { "id": 42, "uuid": "018f6a1e-…", "name": "Nova Games", "provider": "cardzone" },
"order": { "order_id": "1CBDzqwiovwVrtFk6nZ1Aw", "status": "fulfilled", "…": "…" }
}If an order is still processing right after it is created, no status event is sent. The first status_changed event comes with the first real change.
Every delivery is an HTTPS POST request with a JSON body and these headers:
Content-Type: application/json
User-Agent: Cardzone-Webhooks/1.0
X-Cardzone-Event: b2b.order.status_changed
X-Cardzone-Webhook-Id: 9403
X-Cardzone-External-Event-Id: b2b.order.status_changed:1CBDzqwiovwVrtFk6nZ1Aw:2:account_webhook_endpoint:3f9a1c27b4e0d812Watch a receiver handle webhooks
A receiver is the code on your server that accepts webhooks. Play each sequence to see what a correct receiver does with each delivery. The Retries and out-of-order sequence shows all four outcomes:
The four rules
Answer 2xx fast
A
2xxstatus (such as200) means success. Return any2xxas soon as you have saved the event. Do the real work later, in a background job.Anything else counts as a failed delivery:
- a
3xx(Cardzone does not follow redirects) - a
4xxor5xx - a timeout or a connection error
Cardzone retries a failed delivery up to 3 more times over the next few minutes. Then it drops the delivery. That is why you MUST also reconcile on a schedule. If your host resolves to a private or reserved address, Cardzone refuses the delivery and does not retry.
- a
Skip duplicates by event_id
The same event can arrive more than once. Save each
event_idyou have processed. If anevent_idarrives again, answer200and do nothing.Ignore old status events
Deliveries can arrive out of order. The last part of a
status_changedevent_idis a sequence number. Each order has its own sequence, and the number goes up over time. Skip any event whose sequence number is lower than the highest one you have seen for that order.Confirm by polling before you move money or codes
Webhooks are not signed. This means a webhook carries no proof that Cardzone sent it, so anyone could send a fake one to your URL. Treat the webhook body as a hint only. Call
GET /api/b2b/v1/orders/{order_id}with your API key, and act on that response. The poll uses your API key, so a fake webhook can’t fake an order’s state.
Example receiver
Each example saves the webhook’s event_id. If the event is new, it adds a background job. The job skips older sequence numbers, polls the order, and saves the result.
// Express. `db` and `queue` stand in for your own storage and job runner.
app.post("/webhooks/cardzone", express.json(), async (req, res) => {
const { event, event_id: eventId, order } = req.body ?? {};
if (!event?.startsWith("b2b.order.")) return res.sendStatus(200); // not an event this receiver handles
if (!eventId || !order?.order_id) return res.sendStatus(400);
const isNew = await db.insertIgnore("cardzone_events", { event_id: eventId });
if (isNew) await queue.add("cardzone-order-sync", { orderId: order.order_id, eventId });
res.sendStatus(200);
});
// The job: re-fetch, then apply only newer sequences.
async function syncOrder({ orderId, eventId }) {
const sequence = Number(eventId.split(":")[2] ?? 0);
if (sequence && sequence < (await db.highestSequence(orderId))) return;
const order = await cardzone("GET", `/orders/${orderId}`);
await db.saveOrder(order, sequence);
if (order.status === "fulfilled") await deliverCodes(order);
}# Flask
@app.post("/webhooks/cardzone")
def cardzone_webhook():
payload = request.get_json(silent=True) or {}
if not str(payload.get("event", "")).startswith("b2b.order."):
return "", 200 # not an event this receiver handles
event_id = payload.get("event_id")
order_id = (payload.get("order") or {}).get("order_id")
if not event_id or not order_id:
return "", 400
if db.insert_ignore("cardzone_events", event_id=event_id):
queue.enqueue(sync_order, order_id, event_id)
return "", 200
def sync_order(order_id, event_id):
parts = event_id.split(":")
sequence = int(parts[2]) if len(parts) > 2 else 0
if sequence and sequence < db.highest_sequence(order_id):
return
order = cardzone("GET", f"/orders/{order_id}")
db.save_order(order, sequence)
if order["status"] == "fulfilled":
deliver_codes(order)# app/controllers/cardzone_webhooks_controller.rb
class CardzoneWebhooksController < ActionController::API
def create
return head :ok unless params[:event].to_s.start_with?("b2b.order.") # not an event this receiver handles
event_id = params.require(:event_id)
order_id = params.require(:order).require(:order_id)
# cardzone_events.event_id has a unique index.
CardzoneEvent.create!(event_id:)
CardzoneOrderSyncJob.perform_later(order_id, event_id)
head :ok
rescue ActiveRecord::RecordNotUnique
head :ok # duplicate delivery: already queued
end
end
class CardzoneOrderSyncJob < ApplicationJob
def perform(order_id, event_id)
sequence = event_id.split(":")[2].to_i
return if sequence.positive? && sequence < CardzoneOrder.highest_sequence(order_id)
order = Cardzone.get("/orders/#{order_id}")
CardzoneOrder.apply!(order, sequence:)
DeliverCodesJob.perform_later(order_id) if order["status"] == "fulfilled"
end
endReconcile on a schedule
To reconcile means to compare your records with Cardzone’s and fix any differences.
Webhooks can be lost even after retries, for example if your endpoint is down for a while. So run a job every few minutes. The job polls every order that you still have as processing or action_required. Webhooks plus this job keep every order correct.
