A webhook is an HTTPS URL of yours that Idukki calls when something happens in your account, so your own systems (a DAM, a CRM, a data warehouse, an automation tool) stay in step without polling. Each delivery is a JSON POST signed with a secret only you and Idukki know.
Events
| Event | Sent when | Extra fields in data |
|---|---|---|
| ugc.created | A shopper upload lands in your review queue, or a post is imported into a collection. Both arrive as pending. | via (upload or import), status: pending |
| ugc.approved | You approve a post in the dashboard, or approve a shopper upload in review. | via (dashboard or upload_review) |
| rights.granted | A creator approves your rights request on the consent form, or you approve rights in bulk. | via (consent_form or dashboard), expiry (null when the grant does not expire) |
| rights.revoked | You revoke rights in bulk from the dashboard. | via (dashboard), expiry |
| order.attributed | A new order arriving through your store’s order webhook is tied to your UGC, as a proven order or in one of the influence tiers. Historical backfills do not send it. | orderId, attribution, tier, estimated, identifiedVia, orderCreatedAt |
Payloads carry what your own widget already shows: post and media fields, captions cut to 500 characters, the creator’s username. They never include a shopper’s email or phone number.
What a delivery looks like
POST https://your-endpoint.example/idukki
Content-Type: application/json
User-Agent: Idukki-Webhooks/1.0
X-Idukki-Event: ugc.approved
X-Idukki-Delivery: 1234
X-Idukki-Signature: t=1791021302,v1=5f2b9c…(64 hex characters)
{
"id": "evt_1234",
"type": "ugc.approved",
"createdAt": "2026-10-04T10:15:02.412Z",
"data": {
"postId": 48213,
"source": "Instagram_business",
"mediaType": "VIDEO",
"mediaUrl": "https://…",
"thumbnail": "https://…",
"permalink": "https://www.instagram.com/p/…",
"caption": "…",
"username": "…",
"via": "dashboard"
}
}- id is evt_ plus the delivery number, and X-Idukki-Delivery carries the same number. Both stay the same on every retry, so use either to ignore a delivery you have already handled.
- Post events carry postId, source, mediaType, mediaUrl, thumbnail, permalink, caption and username. A shopper upload in ugc.created carries mediaId, mediaType, mediaUrl (a list when the upload has several files), thumbnail, caption, rating, uploadedByName, verifiedBuyer and productIds instead.
- order.attributed carries orderId (your store’s order id), attribution (proven or influenced), tier (clickAttributed, contentAssisted or viewAttributed for influenced orders), estimated (true when the match came from a device fingerprint rather than a direct identifier), identifiedVia (cart_attribute, customer_id, cart_token or fingerprint) and orderCreatedAt.
Verify the signature
X-Idukki-Signature has two parts: t, the Unix time in seconds when the request was signed, and v1, the hex HMAC-SHA256 of t, a full stop, and the raw request body, keyed with your webhook secret. Recompute it over the body exactly as you received it (before parsing the JSON), compare in constant time, and reject a t more than five minutes from your clock so an old request cannot be replayed.
import { createHmac, timingSafeEqual } from 'node:crypto'
// rawBody: the request body exactly as received (string or Buffer), before JSON.parse.
export function verifyIdukkiWebhook(rawBody, signatureHeader, secret, toleranceSeconds = 300) {
if (!signatureHeader) return false
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.trim().split('=')).filter((p) => p.length === 2),
)
const t = Number(parts.t)
if (!Number.isInteger(t) || !parts.v1) return false
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest('hex')
const a = Buffer.from(expected, 'utf8')
const b = Buffer.from(parts.v1, 'utf8')
return a.length === b.length && timingSafeEqual(a, b)
}
// Express: keep the raw body for the signature check.
app.post('/idukki', express.raw({ type: 'application/json' }), (req, res) => {
const raw = req.body.toString('utf8')
if (!verifyIdukkiWebhook(raw, req.get('X-Idukki-Signature'), process.env.IDUKKI_WEBHOOK_SECRET)) {
return res.status(401).end()
}
res.status(200).end() // answer first, then do the work
const event = JSON.parse(raw)
// de-duplicate on req.get('X-Idukki-Delivery'), then switch on event.type
})Delivery and retries
- Any 2xx response counts as delivered. Answer within 10 seconds; a slower answer counts as a failure. Redirects are not followed, so give the final URL.
- A failed delivery is retried with the same body, at the earliest 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours after each failure: six attempts in all, after which it is marked failed. Retries go out in scheduled batches, so one can arrive later than its step.
- Switching a webhook off, or deleting it, cancels its deliveries that have not gone out yet.
- Each delivery is logged with its status (pending, sending, succeeded, retrying, failed or cancelled), the number of attempts, the HTTP status your endpoint last returned, the error if there was one, and the time of the next attempt.
- A webhook problem never holds up the thing that caused the event: an approval, a consent form or an order goes through whatever your endpoint does.
Rules for the URL
- https only, with no username or password in the URL.
- Private, loopback, link-local and other reserved addresses are refused, as are localhost and internal host names (single-word names, .local, .internal). The address is checked again every time Idukki connects, so a name that later points at a private address is refused too.
- Up to 10 webhooks per account, each subscribed to any of the five events.
Set one up with the GraphQL API
Send these to POST https://api.idukki.io/graphql with your dashboard session token as a bearer token. Every operation is scoped to your own account.
mutation {
createOutboundWebhook(input: {
url: "https://your-endpoint.example/idukki"
events: ["ugc.approved", "rights.granted", "order.attributed"]
}) { id secret secretHint events active }
}
# Send one signed webhook.test delivery now (never retried):
mutation { testOutboundWebhook(id: "WEBHOOK_ID") { status httpStatus error } }
# The delivery log, newest first:
query { outboundWebhookDeliveries(webhookId: "WEBHOOK_ID", limit: 20) {
event status httpStatus attempt error nextAttemptAt deliveredAt
} }- The secret (it starts with whsec_) is returned once, by createOutboundWebhook. Store it then; afterwards only its last four characters are shown.
- rotateOutboundWebhookSecret(id) issues a new secret, returned once, and the old one stops signing straight away. Update your endpoint promptly, because deliveries signed with the new secret fail a check against the old one.
- updateOutboundWebhook(id, input: { url events active }) changes the URL or events or switches it off; deleteOutboundWebhook(id) removes it.
Common questions
- Which orders send order.attributed?
- Live orders that reach Idukki through a store order webhook (Shopify, and WooCommerce, BigCommerce or Magento where that webhook is connected) and that Idukki ties to your UGC. Orders it cannot tie to UGC, and orders re-processed in a backfill, are not sent.
- Why did I get the same event twice?
- Your endpoint probably answered after 10 seconds, or with a non-2xx status, so the delivery was retried. Check X-Idukki-Delivery and skip numbers you have already handled.
- My deliveries say “Signing secret unreadable”.
- Rotate the secret and update your endpoint with the new one. Deliveries sign again from then on.