Developers

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_id in 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 happenedDeliveries
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 deactivatedEvery connection is revoked; pending deliveries are marked dead.
The seller’s account was deactivatedNo 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 appEvents that only that scope entitled stop at once, for every seller.

The request

HTTP
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.

Envelope
{
  "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
}
Envelope
event_idstring

evt_ + 24 hex. Unique per event; the same on every retry and replay. Your de-duplication key.

eventstring

Event type, also in the X-Coliswift-Event header. See the catalogue.

sequenceinteger

Assigned by the database when the event is recorded, strictly increasing. Use it to order events.

occurred_attimestamp

When ColiSwift recorded the change.

seller_idstring

The connected seller the event is about.

dataobject

The 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
});

Responding

  • 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 401 from 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 attempt12345678
Next attempt in30 s2 min10 min30 min2 h6 h12 hdead

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 sequence order but in parallel and with retries, so they can arrive out of order. Order them yourself by sequence.
  • Because data is the full state, the safe rule is: for a given subject (an order lead_id, a product), apply an event only if its sequence is 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_changed before lead.items_changed before lead.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.