Webhooks
Subscribe to booking, voucher, and coupon events. Each delivery carries an HMAC-SHA256 signature and retries with exponential backoff.
Subscription CRUD
Create endpoints per tenant. The secret is returned in plaintext only once — on the POST response. Lose it and you must recreate.
POST /webhooks
curl -X POST https://greenflow.live/api/v1/webhooks \
-H "Authorization: Bearer gfc_7s2wprmy_DWTZRBGBEV6La4dTOoFdMkEhWmWCsXdwc3zVMnqP" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/hooks/gfcars",
"events": ["booking.created", "voucher.emitted"]
}'
{
"data": {
"id": 7,
"url": "https://example.com/hooks/gfcars",
"events": ["booking.created", "voucher.emitted"],
"active": true,
"consecutive_failures": 0,
"last_success_at": null,
"last_failure_at": null,
"disabled_at": null,
"created_at": "2026-04-19T03:30:00+00:00",
"secret": "j8R3xT...48-chars...K2pNqL"
}
}
GET /webhooks
curl -s https://greenflow.live/api/v1/webhooks \
-H "Authorization: Bearer gfc_7s2wprmy_DWTZRBGBEV6La4dTOoFdMkEhWmWCsXdwc3zVMnqP"
PATCH /webhooks/{id}
curl -X PATCH https://greenflow.live/api/v1/webhooks/7 \
-H "Authorization: Bearer gfc_7s2wprmy_DWTZRBGBEV6La4dTOoFdMkEhWmWCsXdwc3zVMnqP" \
-H "Content-Type: application/json" \
-d '{ "active": true }'
DELETE /webhooks/{id}
curl -X DELETE https://greenflow.live/api/v1/webhooks/7 \
-H "Authorization: Bearer gfc_7s2wprmy_DWTZRBGBEV6La4dTOoFdMkEhWmWCsXdwc3zVMnqP"
POST /webhooks/{id}/test
curl -X POST https://greenflow.live/api/v1/webhooks/7/test \
-H "Authorization: Bearer gfc_7s2wprmy_DWTZRBGBEV6La4dTOoFdMkEhWmWCsXdwc3zVMnqP"
Signature verification
Each delivery sends X-GFCars-Signature = sha256= + HMAC-SHA256(secret, raw_body). Verify against the raw bytes of the body, not the re-serialized JSON — the signature is computed with JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, and any frameworks that re-parse + re-stringify will break timing-safe equality.
| Header | Value | Uso |
|---|---|---|
| X-GFCars-Event | booking.created | Event discriminator. |
| X-GFCars-Delivery | 123 | Idempotency key — same id ⇒ same attempt. |
| X-GFCars-Signature | sha256=<hex> | HMAC-SHA256 of raw body. |
| X-GFCars-Timestamp | 1745067023 | Unix epoch — enforce replay window. |
Node.js verifier
const crypto = require('crypto');
function verifyGfCarsSignature(rawBody, signatureHeader, secret) {
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) return false;
const expected = signatureHeader.slice('sha256='.length);
const actual = crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex');
return crypto.timingSafeEqual(Buffer.from(expected, 'hex'), Buffer.from(actual, 'hex'));
}
// Express — note the express.raw() middleware to preserve raw bytes.
app.post('/hooks/gfcars', express.raw({ type: 'application/json' }), (req, res) => {
const ok = verifyGfCarsSignature(
req.body,
req.header('X-GFCars-Signature'),
process.env.GFCARS_WEBHOOK_SECRET
);
if (!ok) return res.status(401).end('bad signature');
const ts = parseInt(req.header('X-GFCars-Timestamp'), 10);
if (Math.abs(Date.now() / 1000 - ts) > 300) return res.status(401).end('stale');
const deliveryId = req.header('X-GFCars-Delivery');
const payload = JSON.parse(req.body.toString('utf8'));
// ... process idempotently by deliveryId
res.status(200).end();
});
Python verifier
import hmac, hashlib, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = b"...your-secret..."
def verify(raw_body: bytes, sig_header: str) -> bool:
if not sig_header or not sig_header.startswith("sha256="):
return False
expected = sig_header[len("sha256="):]
actual = hmac.new(SECRET, raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, actual)
@app.post("/hooks/gfcars")
def hooks():
raw = request.get_data()
if not verify(raw, request.headers.get("X-GFCars-Signature", "")):
abort(401, "bad signature")
ts = int(request.headers.get("X-GFCars-Timestamp", "0"))
if abs(time.time() - ts) > 300:
abort(401, "stale")
delivery_id = request.headers["X-GFCars-Delivery"]
# ... process idempotently by delivery_id
return "", 200
Retry policy
Failed deliveries retry up to 5 times with exponential backoff capped at 60 minutes. After 10 consecutive failures across deliveries, the endpoint is auto-disabled — PATCH active = true to re-enable.
| Attempt | Delay |
|---|---|
| 1 | 2 min |
| 2 | 4 min |
| 3 | 8 min |
| 4 | 16 min |
| 5 | 32 min |
| 6+ | 60 min (cap) |
Auto-disable threshold: after WebhookEndpoint::AUTO_DISABLE_THRESHOLD = 10 consecutive failures (across deliveries), the endpoint is paused. Re-enable via PATCH /webhooks/{id} with {"active": true}.
Event catalog
| Event | Fired when | Payload |
|---|---|---|
| booking.created | After a 201 on /bookings. | {"booking_code":"...","local_number":"...","brand":"..."} |
| booking.canceled | After a successful DELETE /bookings/{code}. | {"booking_code":"...","canceled_at":"..."} |
| voucher.emitted | When the PDF is generated and emailed. | {"booking_code":"...","voucher_number":"..."} |
| coupon.applied | On each successful /coupons/validate. | {"code":"...","discount_type":"..."} |
| webhook.test | Manual dispatch via POST /webhooks/{id}/test. | {"message":"hello"} |
["*"] in events subscribes to all (including new events added later).
Idempotency
Deduplicate on X-GFCars-Delivery. The same delivery may arrive multiple times (retry, lock expiry, manual redispatch).