Skip to main content

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

  1. Create an endpoint and subscribe to the events you need.
  2. Copy the signing secret immediately — it is not shown again.
  3. Send a Test ping from the same settings page. heycreo POSTs a webhook.test event so you can confirm connectivity before real traffic.
  4. Verify the signature (below) and respond with 2xx.

Delivery format

Every delivery is a POST with Content-Type: application/json and these headers:

HeaderMeaning
heycreo-signatureHMAC signature (see Verifying the signature)
heycreo-event-typeEvent type, e.g. asset.created
heycreo-event-idStable event id (evt_…), reused across retries
heycreo-delivery-idId of this delivery to this endpoint
heycreo-webhook-idId 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 responseheycreo
2xxDelivered, no retry
408, 429, 5xxRetried
other 4xxPermanent failure, not retried
3xxPermanent 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

TypeWhen it fires
asset.createdMedia is available (upload or AI generation). Use data.asset.origin to tell them apart.
asset.updatedThe file of a media item was replaced. data.asset.contentVersion increments.
asset.deletedA media item was deleted.
export.completedA render finished successfully.
export.failedA render failed. data.export.error contains the message.
publication.publishedEvery destination of a publication is live.
publication.partially_publishedSome destinations succeeded, others failed.
publication.failedA publication ended with no successful destination.
approval.requestedAn approval request was opened on a media item or post.
approval.decidedA 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

FieldDescription
idMedia id
nameDisplay name
statusProcessing status (completed when the file is available)
originHow the item entered heycreo: user_upload, generate, csv_import, template_import, or api_import
mimeType, fileSize, width, height, durationMsFile metadata (durationMs is set for video/audio)
url, previewUrlFile and preview URLs
collectionIdFolder, if any
tags{ id, name }
customFieldsValues keyed by custom-field id
aiContentSourceAI-content marker, or null
contentVersionIncrements when the file is replaced
createdAtISO timestamp

data.export

FieldDescription
id, postIdExport and related post
status, mediaTypeRender status and output kind
imageUrl, videoUrl, pdfUrlOutput URLs (whichever apply)
isCarousel, carouselImageUrlsCarousel outputs, if any
width, height, aspectRatioOutput dimensions
errorPresent on export.failed

data.publication

FieldDescription
id, postIdPublication and related post
statusOverall publication status
scheduledAt, publishedAtISO timestamps
destinations[]Per-platform result: platform, status, externalPostId, publishedUrl, error

data.approval

FieldDescription
idApproval request id
entityTypeasset or post
entityIdThe media item or post under review
statusRequest as a whole (pending, approved, rejected, cancelled)
decisionThe reviewer’s vote on approval.decided (approved or rejected); null on approval.requested
requestedByUserIdWho opened the request
reviewerUserIdWho voted; null on approval.requested
entityContentVersionContent version at the time of the request
closingDateDeadline, 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 customFields back after you receive asset.created