Webhooks
Webhooks notify your HTTPS endpoint when something finishes in heycreo: media becomes available, an export renders, or a publication goes live.
Create endpoints in the product under Settings → Webhooks. Each endpoint
has its own signing secret (whsec_…), shown once when you create the endpoint
or rotate the secret.
Quickstart
- Create an endpoint and subscribe to the events you need.
- Copy the signing secret immediately — it is not shown again.
- Send a Test ping from the same settings page. heycreo POSTs a
webhook.testevent so you can confirm connectivity before real traffic. - Verify the signature (below) and respond with
2xx.
Delivery format
Every delivery is a POST with Content-Type: application/json and these
headers:
| Header | Meaning |
|---|---|
heycreo-signature | HMAC signature (see Verifying the signature) |
heycreo-event-type | Event type, e.g. asset.created |
heycreo-event-id | Stable event id (evt_…), reused across retries |
heycreo-delivery-id | Id of this delivery to this endpoint |
heycreo-webhook-id | Id of the receiving endpoint |
The body is always the same envelope. Only data changes per event type.
{
"id": "evt_00000000-0000-4000-8000-000000000001",
"type": "asset.created",
"createdAt": "2026-01-15T10:04:12.000Z",
"organization": {
"id": "00000000-0000-4000-8000-0000000000aa",
"slug": "acme"
},
"data": {
"asset": {
"id": "00000000-0000-4000-8000-0000000000bb",
"name": "Summer campaign hero",
"status": "completed",
"origin": "user_upload",
"mimeType": "image/png",
"fileSize": 1240000,
"width": 1920,
"height": 1080,
"durationMs": null,
"url": "https://files.example.com/asset.png",
"previewUrl": "https://files.example.com/asset-preview.webp",
"collectionId": null,
"tags": [{ "id": "00000000-0000-4000-8000-0000000000cc", "name": "Campaign" }],
"customFields": {},
"aiContentSource": null,
"contentVersion": 1,
"createdAt": "2026-01-15T10:04:12.000Z"
}
}
}
Verifying the signature
The heycreo-signature header looks like this:
heycreo-signature: t=1736937852,v1=8f9c…3ab1
v1 is HMAC-SHA256 of "{t}.{rawBody}", keyed with your endpoint secret.
rawBody must be the unparsed request body. Parsing and re-serializing
JSON will change whitespace and fail the comparison.
Reject requests whose t is more than five minutes away from your clock. That
stops replay of a captured payload.
import { createHmac, timingSafeEqual } from 'crypto'
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map((p) => p.trim().split('=')),
)
if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false
const expected = createHmac('sha256', secret)
.update(`${parts.t}.${rawBody}`)
.digest('hex')
return timingSafeEqual(Buffer.from(parts.v1), Buffer.from(expected))
}
import hashlib
import hmac
import time
def verify(raw_body: bytes, signature_header: str, secret: str) -> bool:
parts = dict(
p.strip().split("=", 1) for p in signature_header.split(",")
)
if abs(time.time() - int(parts["t"])) > 300:
return False
expected = hmac.new(
secret.encode(),
f"{parts['t']}.".encode() + raw_body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(parts["v1"], expected)
Responding and retries
Return any 2xx as soon as you have stored the event. Do the actual work
asynchronously — the request times out after 10 seconds.
Failed deliveries are retried with exponential backoff, up to 6 attempts:
| Your response | heycreo |
|---|---|
2xx | Delivered, no retry |
408, 429, 5xx | Retried |
other 4xx | Permanent failure, not retried |
3xx | Permanent failure — redirects are not followed |
If an endpoint fails repeatedly, heycreo disables it. Re-enable it in Settings → Webhooks after you have fixed the receiver.
Idempotency
Deduplicate on heycreo-event-id. Retries reuse that id, and the same
occurrence never produces two different event ids.
Do not use heycreo-delivery-id as the key. Replaying a delivery from the
settings page creates a new delivery for the same event.
Event types
| Type | When it fires |
|---|---|
asset.created | Media is available (upload or AI generation). Use data.asset.origin to tell them apart. |
asset.updated | The file of a media item was replaced. data.asset.contentVersion increments. |
asset.deleted | A media item was deleted. |
export.completed | A render finished successfully. |
export.failed | A render failed. data.export.error contains the message. |
publication.published | Every destination of a publication is live. |
publication.partially_published | Some destinations succeeded, others failed. |
publication.failed | A publication ended with no successful destination. |
approval.requested | An approval request was opened on a media item or post. |
approval.decided | A reviewer approved or rejected. data.approval.decision is that vote; status is the request as a whole. |
A Test ping sends webhook.test. You cannot subscribe to it — it is always
delivered to the endpoint you tested.
data.asset
| Field | Description |
|---|---|
id | Media id |
name | Display name |
status | Processing status (completed when the file is available) |
origin | How the item entered heycreo: user_upload, generate, csv_import, template_import, or api_import |
mimeType, fileSize, width, height, durationMs | File metadata (durationMs is set for video/audio) |
url, previewUrl | File and preview URLs |
collectionId | Folder, if any |
tags | { id, name } |
customFields | Values keyed by custom-field id |
aiContentSource | AI-content marker, or null |
contentVersion | Increments when the file is replaced |
createdAt | ISO timestamp |
data.export
| Field | Description |
|---|---|
id, postId | Export and related post |
status, mediaType | Render status and output kind |
imageUrl, videoUrl, pdfUrl | Output URLs (whichever apply) |
isCarousel, carouselImageUrls | Carousel outputs, if any |
width, height, aspectRatio | Output dimensions |
error | Present on export.failed |
data.publication
| Field | Description |
|---|---|
id, postId | Publication and related post |
status | Overall publication status |
scheduledAt, publishedAt | ISO timestamps |
destinations[] | Per-platform result: platform, status, externalPostId, publishedUrl, error |
data.approval
| Field | Description |
|---|---|
id | Approval request id |
entityType | asset or post |
entityId | The media item or post under review |
status | Request as a whole (pending, approved, rejected, cancelled) |
decision | The reviewer’s vote on approval.decided (approved or rejected); null on approval.requested |
requestedByUserId | Who opened the request |
reviewerUserId | Who voted; null on approval.requested |
entityContentVersion | Content version at the time of the request |
closingDate | Deadline, or null |
Debugging
Settings → Webhooks keeps a delivery log for 30 days. Each entry includes the JSON that was sent, the response from your server, and any error. You can replay a failed delivery from there.
See also
- Assets — write
customFieldsback after you receiveasset.created