Webhooks
Register one endpoint per gallery and receive a signed JSON event whenever the gallery's content changes.
Webhooks are the push counterpart to the REST API. A gallery registers one
endpoint and the platform POSTs a signed JSON event to it whenever the
gallery's content changes — so you don't have to poll /api/v1.
Subscribing
Register your endpoint in the gallery sidebar's Developer → Webhooks section in the app and select the events you want. That page is also where the signing secret is revealed — copy it; you'll need it to verify deliveries. There is one endpoint per gallery; webhook subscriptions are managed in-app, not through an API key.
Event types
Events are named resource.action. Currently emitted:
| Resource | Events |
|---|---|
| Artworks | artwork.created, artwork.updated, artwork.deleted, artwork.restored, artwork.interest_added |
| Artists | artist.created, artist.updated, artist.deleted |
| Gallery | gallery.updated, gallery.member_added, gallery.member_removed, gallery.member_role_changed |
| Contacts | contact.created, contact.updated, contact.deleted, contact.erased, interaction.logged |
| Offers | offer.sent, offer.opened, offer.artwork_viewed |
| Invoices & payments | invoice.issued, invoice.paid, payment.recorded, payment.refunded |
| Payables | payable.created, payable.paid |
| Publications | publication.updated |
| Lists | list.created, list.updated, list.deleted |
| Shows & programs | show.created, show.updated, show.published, show.deleted, program.created, order.paid, order.refunded |
| Jobs (art logistics) | job.created, job.assigned, job.status_changed, job.task_completed, job.completed, condition_report.created |
| Legal documents | legal_document.generated, legal_document.sent, legal_document.accepted |
The in-app Webhooks page always shows the live event catalog — new event types are added as the platform grows, so ignore event types you don't recognize rather than failing on them.
The delivery request
POST <your endpoint>
Content-Type: application/json
User-Agent: GalleryPlatform-Webhooks/1
x-gallery-webhook-id: 8f0c…
x-gallery-webhook-event: artwork.created
x-gallery-webhook-signature: t=1750507200,v1=<hex>{
"id": "8f0c…",
"type": "artwork.created",
"created_at": "2026-06-21T12:00:00.000Z",
"gallery_id": "…",
"data": { }
}data carries a snapshot of the affected resource, taken at emit time. Its
shape follows the event's verb:
*.created,*.updated,*.publishedon artworks, artists, contacts, lists, shows, and programs carry the resource itself at the top level — the same fields the matchingGETreturns.- Invoices, payables, jobs, and condition reports carry the resource under
its own name (
data.invoice,data.payable,data.job,data.condition_report). *.deleted,*.restored,*.erasedand membership changes carry onlydata.id(the resource) or the ids that changed.- Lifecycle events (
offer.*,order.*,payment.*,job.*progress,legal_document.*, gallery membership) carry a small object of ids and the fields that changed, with the entity's id first.
Every event names the resource it concerns — data.id or data.<entity>_id —
so treat data as the hint and re-read the resource with the matching GET
before acting on it: a later event can supersede an earlier one in flight.
Verifying the signature
The x-gallery-webhook-signature header is t=<unix timestamp>,v1=<hex>, where
v1 is HMAC-SHA256(secret, "<t>.<rawBody>") — it signs the timestamp and
the raw request body, so both are tamper-evident.
Both SDKs ship the verifier, pinned to the platform's own signer — hand it the raw request body, never re-serialised JSON:
import { verifyWebhookSignature, WEBHOOK_SIGNATURE_HEADER } from "gallery-platform";
const ok = await verifyWebhookSignature(
process.env.GALLERY_WEBHOOK_SECRET!,
rawBody, // string or Uint8Array, exactly as received
req.headers.get(WEBHOOK_SIGNATURE_HEADER),
);from gallery_platform import verify_webhook_signature, WEBHOOK_SIGNATURE_HEADER
ok = verify_webhook_signature(secret, request.body, request.headers.get(WEBHOOK_SIGNATURE_HEADER))Doing it by hand instead:
- Read
tandv1from the header. - Compute
HMAC-SHA256(secret, t + "." + rawRequestBody)(use the raw body bytes, before JSON parsing). - Compare against
v1with a constant-time comparison. - Reject deliveries whose
tis too old to bound replay.
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 5 * 60;
function verify(secret: string, rawBody: string, header: string): boolean {
const parts = Object.fromEntries(
header.split(",").map((kv) => kv.split("=")),
);
if (!parts.t || !parts.v1) return false;
// Bound replay: reject signatures older than the tolerance.
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const received = Buffer.from(parts.v1);
// timingSafeEqual throws on length mismatch — check length first.
return (
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(expected), received)
);
}Delivery, retries & idempotency
- Delivery is attempted immediately after the change, then retried with backoff (1m → 5m → 30m → 2h) up to 5 attempts before the delivery is marked failed.
- Because of retries, an event can be delivered more than once. Deduplicate
on
x-gallery-webhook-id(also the payloadid) — it's stable per event. - Respond
2xxpromptly to acknowledge; do slow work asynchronously.