Webhooks
Instead of polling, give GoOrder an HTTPS URL and we will POST an event to it whenever something changes.
Register your endpoint
Section titled “Register your endpoint”curl -X PUT https://back.goorder.com/api/partner/v1/webhooks/endpoint \ -H "Authorization: Bearer $GOORDER_API_KEY" -H "Content-Type: application/json" \ -d '{ "url": "https://partner.example.com/hooks/goorder", "events": ["order.status_changed", "order.payment_updated"] }'The first time, the response contains a signing secret (whsec_…). It is shown once — store it. events is optional; leave it out to receive everything. (Needs the webhooks:manage scope. Your GoOrder contact can also set this up for you.)
| Rule | Detail |
|---|---|
| Scheme | https:// only in production |
| Reachability | Public hosts only — private, loopback and link-local addresses are refused, and checked again at delivery time |
| Redirects | Not followed |
| Timeout | We wait 8 seconds for your reply |
| Success | Any 2xx response |
Events
Section titled “Events”| Type | Sent when |
|---|---|
order.created | An order is created through the API |
order.status_changed | The public status changes. data.previous_status is the status before |
order.payment_updated | The payment status changes (for example a card payment succeeds) |
order.refunded | The order becomes refunded or partially_refunded |
webhook.test | You asked for a test event |
Every event has the same envelope:
{ "id": "evt_5b1e0c7d8a9f", "object": "event", "type": "order.status_changed", "created": "2026-10-12T09:52:05.000Z", "environment": "live", "data": { "previous_status": "preparing", "order": { "id": "66f1a2b3c4d5e6f708192a3b", "object": "order", "status": "out_for_delivery", "reference": "PARTNER-100234", "…": "the same Order object the API returns" } }}The order is a full snapshot, so you rarely need to call back to fetch it.
Verify the signature
Section titled “Verify the signature”Anyone can POST to your URL. Verify every request before acting on it.
Each request carries:
| Header | Value |
|---|---|
GoOrder-Signature | t=<unix seconds>,v1=<hex HMAC> |
GoOrder-Event-Id | The event id (same as id in the body) |
GoOrder-Event-Type | The event type |
GoOrder-Timestamp | The t value |
The signature is HMAC-SHA256(secret, "<t>.<raw request body>"), hex-encoded. You must use the raw bytes of the body, exactly as received — not a re-serialised JSON object.
- Read the raw body.
- Split the header into
tandv1. - Compute
HMAC-SHA256(secret, t + "." + rawBody)and compare it tov1with a constant-time comparison. - Reject if
tis more than 5 minutes from your clock (replay protection). - Only then parse the JSON.
import crypto from 'node:crypto';import express from 'express';
const app = express();
// Keep the body raw for this route — signature checks need the exact bytes.app.post('/hooks/goorder', express.raw({ type: 'application/json' }), (req, res) => { const header = req.get('GoOrder-Signature') || ''; const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); const t = Number(parts.t); const expected = crypto .createHmac('sha256', process.env.GOORDER_WEBHOOK_SECRET) .update(`${parts.t}.${req.body}`) // req.body is a Buffer here .digest('hex');
const valid = parts.v1 && parts.v1.length === expected.length && crypto.timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected)); const fresh = Math.abs(Date.now() / 1000 - t) < 300; if (!valid || !fresh) return res.status(400).send('bad signature');
const event = JSON.parse(req.body.toString('utf8')); handleEvent(event).catch(console.error); // do the work after acknowledging res.sendStatus(200);});import hashlib, hmac, os, timefrom flask import Flask, request, abort
app = Flask(__name__)
@app.post("/hooks/goorder")def goorder_hook(): raw = request.get_data() # raw bytes, before any JSON parsing parts = dict(p.split("=", 1) for p in request.headers.get("GoOrder-Signature", "").split(",")) expected = hmac.new( os.environ["GOORDER_WEBHOOK_SECRET"].encode(), f"{parts.get('t', '')}.".encode() + raw, hashlib.sha256, ).hexdigest()
if not hmac.compare_digest(parts.get("v1", ""), expected): abort(400) if abs(time.time() - int(parts["t"])) > 300: abort(400)
handle_event(request.get_json()) return "", 200<?php$raw = file_get_contents('php://input');parse_str(str_replace(',', '&', $_SERVER['HTTP_GOORDER_SIGNATURE'] ?? ''), $parts);
$expected = hash_hmac('sha256', ($parts['t'] ?? '') . '.' . $raw, getenv('GOORDER_WEBHOOK_SECRET'));
if (!hash_equals($expected, $parts['v1'] ?? '') || abs(time() - (int) ($parts['t'] ?? 0)) > 300) { http_response_code(400); exit('bad signature');}
$event = json_decode($raw, true);handle_event($event);http_response_code(200);Retries
Section titled “Retries”If your endpoint does not answer 2xx within 8 seconds, we retry with increasing delays:
| Attempt | Waits after the previous failure |
|---|---|
| 1 | immediately |
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 6 hours |
| 7 | 12 hours |
After the 7th failed attempt (about 20 hours) the delivery is marked failed. You can see every delivery with GET /webhooks/deliveries (kept for 30 days) and re-send any of them with POST /webhooks/deliveries/{id}/redeliver after fixing your endpoint.
Handle events safely
Section titled “Handle events safely”- Acknowledge fast. Return
200immediately and do the work asynchronously. A slow handler causes retries. - Be idempotent. The same event can arrive more than once (a retry after your
200was lost). Remember processedids, or make your updates safe to repeat. - Do not assume order. Retries can reorder events. Use
data.order.statusanddata.order.updated_atto ignore anything older than what you already have. - Fetch if in doubt.
GET /orders/{id}always returns the current truth.
Test it
Section titled “Test it”curl -X POST https://back.goorder.com/api/partner/v1/webhooks/test \ -H "Authorization: Bearer $GOORDER_API_KEY"You receive a webhook.test event. For realistic traffic, create a sandbox order and advance it — each step sends a signed event with "environment": "sandbox".