Webhooks
How ColiSwift delivers events: subscription, the signed request, the envelope, verification, retries, ordering, idempotency and replay.
ColiSwift records an event every time something your app may know about changes for a connected seller, and POSTs it to your app’s webhook URL. Events are written in the same database transaction as the change, so a committed change never loses its event.
Subscribing
- One webhook URL per app, set on the app (by you on the sandbox, by ColiSwift in production). It receives the events of every seller connected to your app;
seller_idin the envelope tells them apart. - By default you receive every event your scopes entitle you to (Scopes). Optionally the app can be restricted to a list of event types; the filter is per app, not per seller.
- There is no API to change the URL or the filter: edit your app on the sandbox; in production, ask ColiSwift.
- An event is delivered for a seller only while their connection is live. Order (
lead.*) events go only to the app that created (or claimed) the order.
When deliveries stop
Whether a delivery may go is checked when the event is recorded and again when it is sent, so a change takes effect on deliveries already queued:
| What happened | Deliveries |
|---|---|
The connection was revoked (the seller disconnected your app, /oauth/revoke, a reused refresh token) | Nothing more is delivered for that seller. Deliveries still pending are marked dead (grant_revoked), and a replay is refused (409 GRANT_REVOKED). |
| The app was deactivated | Every connection is revoked; pending deliveries are marked dead. |
| The seller’s account was deactivated | No new event is sent for that seller; deliveries already queued wait. The connection is not revoked: if the account is reactivated, they go out again. |
| A scope was removed from your app | Events that only that scope entitled stop at once, for every seller. |
The request
POST /coliswift/webhooks HTTP/1.1
Host: app.example.com
Content-Type: application/json
User-Agent: ColiSwift-Webhooks/1.0
X-Coliswift-Event: product.changed
X-Coliswift-Delivery: 0b7c2e9a-… (delivery id — same on every retry)
X-Coliswift-Signature: 5d41402abc4b2a76b9719d911017c592… (hex HMAC-SHA256)
{"data":{…},"event":"product.changed","event_id":"evt_…","occurred_at":"…","seller_id":"…","sequence":4101}The body is compact JSON with keys sorted alphabetically at every level (shown pretty-printed below). It is serialised once when the event is recorded and every attempt sends the very same bytes.
{
"data": {
"category": "beauty",
"description": "Brightening serum.",
"image_url": "https://cdn.example.com/serum.jpg",
"label": "Vitamin C serum 30 ml",
"product_id": "5e6f7a8b-9c0d-4e1f-a2b3-c4d5e6f7a8b9",
"reference": "SER-01"
},
"event": "product.changed",
"event_id": "evt_5f1c9a0b2d3e4f5a6b7c8d9e",
"occurred_at": "2026-11-02T11:20:41.302Z",
"seller_id": "6a1c2f0e-3b7d-4c55-9e10-2f8a4b7c9d01",
"sequence": 4101
}event_idstringevt_+ 24 hex. Unique per event; the same on every retry and replay. Your de-duplication key.eventstringEvent type, also in the
X-Coliswift-Eventheader. See the catalogue.sequenceintegerAssigned by the database when the event is recorded, strictly increasing. Use it to order events.
occurred_attimestampWhen ColiSwift recorded the change.
seller_idstringThe connected seller the event is about.
dataobjectThe payload — for most events the full current state of the subject, not a patch.
Verifying the signature
X-Coliswift-Signature is the lowercase hex HMAC-SHA256 of the raw request body keyed with your webhook secret (whsec_… as given, the whole string). No prefix, no timestamp. Compute it over the bytes you received — before any JSON parsing — and compare in constant time. Reject with 401 when it does not match.
import crypto from 'node:crypto';
import express from 'express';
const app = express();
// The RAW body: the signature covers the exact bytes received.
app.post('/coliswift/webhooks', express.raw({ type: 'application/json' }), (req, res) => {
const expected = crypto
.createHmac('sha256', process.env.COLISWIFT_WEBHOOK_SECRET) // whsec_…
.update(req.body)
.digest('hex');
const given = String(req.get('X-Coliswift-Signature') ?? '');
const ok = given.length === expected.length
&& crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
if (!ok) return res.status(401).end();
const event = JSON.parse(req.body.toString('utf8'));
queue.push(event); // process asynchronously
res.status(200).end(); // answer fast
});<?php
$rawBody = file_get_contents('php://input');
$secret = getenv('COLISWIFT_WEBHOOK_SECRET'); // whsec_…
$given = $_SERVER['HTTP_X_COLISWIFT_SIGNATURE'] ?? '';
$expected = hash_hmac('sha256', $rawBody, $secret);
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
// enqueue $event, then:
http_response_code(200);import hashlib, hmac, json, os
from flask import Flask, request, abort
app = Flask(__name__)
@app.post("/coliswift/webhooks")
def coliswift_webhook():
raw = request.get_data() # exact bytes
expected = hmac.new(
os.environ["COLISWIFT_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
).hexdigest()
given = request.headers.get("X-Coliswift-Signature", "")
if not hmac.compare_digest(expected, given):
abort(401)
event = json.loads(raw)
enqueue(event) # process asynchronously
return "", 200Responding
- Any 2xx within 10 seconds marks the delivery delivered. The response body is never read — the deliveries list shows only the HTTP status (or a generic reason: timeout, connection failed, DNS lookup failed…).
- Anything else is a failure: another status, a timeout, a network or TLS error, and any 3xx — redirects are not followed.
- Your webhook host must resolve to public addresses only. ColiSwift resolves it at every attempt and connects to the address it checked; a name resolving to a private, loopback, link-local or other internal address is a failed attempt (« webhook target not allowed »).
- A
401from you is recorded as « signature rejected » (usually a secret rotated on one side only) and retried like other failures. - Acknowledge first, work later: put the event on a queue and answer.
Retries
A failed delivery is retried on this schedule, then marked dead after the 8th failed attempt (about 20 h 40 min after the first):
| After failed attempt | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
|---|---|---|---|---|---|---|---|---|
| Next attempt in | 30 s | 2 min | 10 min | 30 min | 2 h | 6 h | 12 h | dead |
A dead delivery is announced to the seller (« … is not receiving updates ») and to ColiSwift administrators. Delivered rows are kept 30 days; dead ones until someone replays them.
Before every attempt the delivery is checked against what your app may receive now: a live connection to the seller, a scope that still covers the event, and a stored body holding nothing your current scopes may not see (for cod_drop.catalog_changed, also the catalogue synchronisation and the seller’s COD Drop access). A delivery no longer entitled is cancelled — not sent, not retried — and listed as cancelled with its reason.
Replay
A ColiSwift administrator can replay any delivery of your app, and a seller can replay a dead delivery from their Integrations page. A replay restarts the full retry schedule and sends the same bytes, same event_id, same sequence — re-signed with the current webhook secret. A delivery to an app that is no longer connected to the seller cannot be replayed (409 GRANT_REVOKED): it would send the seller’s data to an app they disconnected. Nor can a stored body holding a field your app may not see now (409 EVENT_NOT_GRANTED). There is no replay endpoint for apps; use the read-back endpoints to catch up on your own.
Ordering
- Deliveries are attempted in
sequenceorder but in parallel and with retries, so they can arrive out of order. Order them yourself bysequence. - Because
datais the full state, the safe rule is: for a given subject (an orderlead_id, a product), apply an event only if itssequenceis higher than the last one you applied. - Several changes in a short burst are merged into one event describing the latest state; intermediate states may never be sent. Do not rely on seeing every individual status.
- Within one change, events are emitted in life-cycle order (e.g.
lead.confirmation_status_changedbeforelead.items_changedbeforelead.delivery_status_changed).
Idempotency
You can receive the same event more than once (a retry after your 2xx was lost, a replay). Store the event_ids you processed and skip repeats. Applying a full-state event twice is harmless by construction.
Latency
Changes are captured by database triggers and turned into events by a background worker woken immediately and at least every 60 seconds; deliveries are attempted right away and swept every 5 seconds. Expect seconds, sometimes up to about a minute. Never block a user flow on a webhook.
Catching up after an outage
Webhooks plus a periodic read-back is the robust pattern: GET /orders?updatedSince= for your orders, GET /products?updatedSince= for the catalogue, and GET /cod-drop/catalog/{productId} for a catalogue item.

