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.interest_added |
| 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 |
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.
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.
To verify:
- 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.