Guides
Webhooks
Register an address and GogoScreen posts you a signed event whenever a piece of work reaches a terminal state, so you can stop polling.
Registering an endpoint
POST /webhooks takes the address to post to, the events you want and an optional description. It needs the webhooks:manage scope.
curl -X POST https://api.gogoscreen.com/api/v1/webhooks \
-H "Authorization: Bearer $GOGOSCREEN_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"url": "https://hooks.example.com/gogoscreen",
"events": ["render.succeeded", "render.failed"],
"description": "Production receiver"
}'HTTP/1.1 201 Created
{
"secret": "whsec_EXAMPLE_SECRET_SHOWN_ONCE",
"endpoint": {
"id": "3f2b0c1a-9d4e-4a61-b8c2-000000000014",
"url": "https://hooks.example.com/gogoscreen",
"events": [
"render.succeeded",
"render.failed"
],
"description": "Production receiver",
"status": "active",
"failureStreak": 0,
"disabledAt": null,
"disabledReason": null,
"lastDeliveryAt": null,
"createdAt": "2026-09-21T11:19:00.000Z",
"updatedAt": "2026-09-21T11:19:00.000Z"
}
}An account may hold 10 active endpoints. An eleventh is 409 too_many_endpoints with details.max and details.active. Disabled endpoints do not count, and re-enabling one is checked against the same cap.
The 16 event types
An event name is a promise. Once it is on this list an integration subscribes to it and branches on it, so the list is small, it is frozen, and it is derived from the one thing the whole public surface already agrees about: the terminal states of the job envelope.
There is no .queued and no .running. An event per stage would be a stream rather than a notification, and the reason to want webhooks at all is to stop polling; a webhook that fires three times before the answer exists has saved you nothing. Use GET for those.
| Resource | .succeeded | .failed | .cancelled |
|---|---|---|---|
marketing.exportA marketing video being cut | marketing.export.succeeded | marketing.export.failed | marketing.export.cancelled |
marketing.generationA marketing project being generated | marketing.generation.succeeded | marketing.generation.failed | marketing.generation.cancelled |
marketing.sourceA website scan | marketing.source.succeeded | marketing.source.failed | marketing.source.cancelled |
renderA walkthrough video | render.succeeded | render.failed | render.cancelled |
storyboardA walkthrough plan | storyboard.succeeded | storyboard.failed | storyboard.cancelled |
Plus webhook.test, which only POST /webhooks/{id}/test sends. Subscribe to it if you want to be able to press that button.
An unknown name is refused rather than ignored, because an endpoint subscribed to a typo would silently never fire. Send at least one.
Test it before you trust it
POST /webhooks/{id}/test sends a real signed delivery through the real delivery path. A test that took a shortcut would only prove the shortcut works.
HTTP/1.1 202 Accepted
{
"deliveryId": "3f2b0c1a-9d4e-4a61-b8c2-000000000015",
"eventId": "3f2b0c1a-9d4e-4a61-b8c2-000000000016"
}Then read the delivery log. GET /webhooks/{id}/deliveries is a cursor page, newest first, and it is where you find out what your own server answered.
HTTP/1.1 200 OK
{
"data": [
{
"id": "3f2b0c1a-9d4e-4a61-b8c2-000000000015",
"endpointId": "3f2b0c1a-9d4e-4a61-b8c2-000000000014",
"eventId": "3f2b0c1a-9d4e-4a61-b8c2-000000000016",
"eventType": "webhook.test",
"attempt": 1,
"status": "succeeded",
"responseStatus": 200,
"responseMs": 123,
"lastError": null,
"nextAttemptAt": null,
"deliveredAt": "2026-09-21T11:17:00.000Z",
"createdAt": "2026-09-21T11:17:00.000Z",
"updatedAt": "2026-09-21T11:17:00.000Z"
}
],
"nextCursor": null
}What arrives
A delivery is a POST with a JSON body and five headers of ours. The body is the same shape for every type: the identity of the event, and the job envelope as data. One thing to parse.
POST /gogoscreen HTTP/1.1
Host: hooks.example.com
Content-Type: application/json
Content-Length: 382
User-Agent: Gogoscreen-Webhooks/1.0
Gogoscreen-Event-Id: 9f9f1c50-2d23-4655-bd53-6c71485d1d55
Gogoscreen-Event-Type: webhook.test
Gogoscreen-Delivery-Id: 01fb5982-302e-40a9-aa0c-223cf480ddf2
Gogoscreen-Delivery-Attempt: 1
Gogoscreen-Signature: t=1790007388,v1=7b1408d576e9cb3c92fe77022cc4afb3af57f14ba81232650b7fbc9a41366d89
{"id":"9f9f1c50-2d23-4655-bd53-6c71485d1d55","data":{"sentAt":"2026-09-21T16:16:28.422Z","message":"This is a test delivery from Gogoscreen. If you can verify its signature, your endpoint is ready.","requestId":"951a5f88-f25a-4e8f-9616-81a302eab2b5","endpointId":"1bb1ee72-8b7b-405e-86fa-eabfcd5f40b9"},"type":"webhook.test","createdAt":"2026-09-21T16:16:28.422Z","apiVersion":"v1"}| Header | What it is |
|---|---|
Gogoscreen-Event-Id | The event. Stable across every attempt of one event, so this is the field to deduplicate on. |
Gogoscreen-Event-Type | One of the event types above. The same value as body.type. |
Gogoscreen-Delivery-Id | This attempt's delivery log entry, which GET /webhooks/{id}/deliveries lists. Not a dedupe key. |
Gogoscreen-Delivery-Attempt | 1 to 6. |
Gogoscreen-Signature | t=<unix seconds>,v1=<hex>. See below. |
| Name | Type | Description |
|---|---|---|
| id | string | The event id. The same value as Gogoscreen-Event-Id. |
| type | string | The event type. |
| createdAt | string | When the event was made, ISO 8601. |
| apiVersion | string | Which version of the API shaped data. "v1" for as long as /api/v1 is. |
| data | object | The job envelope for the resource, exactly as GET would answer it, plus a requestId naming the request that started the work. See the Jobs guide for every field. |
{
"id": "9f9f1c50-2d23-4655-bd53-6c71485d1d55",
"data": {
"sentAt": "2026-09-21T16:16:28.422Z",
"message": "This is a test delivery from Gogoscreen. If you can verify its signature, your endpoint is ready.",
"requestId": "951a5f88-f25a-4e8f-9616-81a302eab2b5",
"endpointId": "1bb1ee72-8b7b-405e-86fa-eabfcd5f40b9"
},
"type": "webhook.test",
"createdAt": "2026-09-21T16:16:28.422Z",
"apiVersion": "v1"
}On a resource event, data is the job envelope for that resource, field for field as GET answers it, with the originating requestId added. The Jobs guide documents every field of it and shows executed examples, so nothing is repeated here.
Verifying a delivery
The header is Gogoscreen-Signature: t=<unix seconds>,v1=<hex>, where the hex is HMAC-SHA256(secret, "<t>." + rawBody) in lower case. Three properties matter, and each shows up in the code.
- The timestamp is signed, not just sent. It is inside the MAC input, so an attacker who recorded a delivery cannot give it a fresh
twithout forging the MAC. Reject anything more than 300 seconds out. - The compare is constant time, and the length is checked first because Node's
timingSafeEqualthrows on a length mismatch. - The body is bytes, not an object. Verify against the raw request body before anything parses it.
const crypto = require("node:crypto");
/**
* Is this delivery ours, and is it recent?
*
* @param {string} secret the whsec_… value POST /webhooks returned once
* @param {string} header the Gogoscreen-Signature value as received
* @param {string} rawBody the RAW request body, before any JSON parse
*/
function verifyGogoscreenSignature(secret, header, rawBody, toleranceSeconds = 300) {
if (typeof header !== "string") return false;
// t=…,v1=… . First value wins: a header carrying v1 twice is an attempt to
// have the verifier read one value and a logger read another.
const parts = {};
for (const piece of header.split(",")) {
const eq = piece.indexOf("=");
if (eq <= 0) continue;
const k = piece.slice(0, eq).trim();
if (!(k in parts)) parts[k] = piece.slice(eq + 1).trim();
}
const t = Number(parts.t);
if (!Number.isFinite(t) || !parts.v1) return false;
// The replay window. The timestamp is INSIDE the MAC, so a recorded delivery
// cannot be given a fresh t without forging the signature.
if (Math.abs(Math.floor(Date.now() / 1000) - t) > toleranceSeconds) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`, "utf8")
.digest("hex");
// Length first: timingSafeEqual THROWS on a length mismatch.
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(parts.v1, "utf8");
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
module.exports = { verifyGogoscreenSignature };The server signs with the same algorithm as the code above, and its own verifier agrees with both of these on a real delivery, on a tampered body, on a tampered signature and on an out of window timestamp.
Delivery, retries and giving up
| Property | Value |
|---|---|
| Guarantee | At least once. The same event id can arrive more than once; dedupe on it. |
| Attempts | 6 |
| Backoff | exponential, on a 30 s base. The gaps between the 6 attempts are 30 s, 1.5 min, 3.5 min, 7.5 min, 15.5 min, which is 28.5 minutes from the first attempt to the last. |
| Success | Any 2xx, answered within 10 seconds. The socket timeout is the ceiling on one attempt. |
| Redirects | A 3xx is recorded as a failure and is never followed, because the target of a redirect has not passed the address checks below. |
| Response bodies | Never stored. The delivery log keeps a status line or an error code, truncated to 256 characters, because an error page may echo your own secrets and the delivery log is readable over the API. |
| User agent | Gogoscreen-Webhooks/1.0 |
| Auto disable | 20 consecutive failed deliveries switch the endpoint off. A single success resets the streak to zero. |
Answer first, work after. The 10 second budget is for your response, not for your processing. Acknowledge the delivery, put the event on your own queue, and do the work there.
Do not trust nextAttemptAt in the delivery log. Attempts are spaced 30 s, 1.5 min, 3.5 min, 7.5 min, 15.5 min apart, but the timestamp written to the log is an estimate that is earlier than the attempt will actually happen, by more each time. The attempt number, the status and lastError on the same entry are accurate; only the predicted time is not.
When an endpoint is switched off, GET /webhooks/{id} shows status: "disabled", the failureStreak that did it and a disabledReason of consecutive_failures. Every delivery still pending for it is marked failed, so a customer who has been told their endpoint is off does not receive a message from it an hour later.
To turn it back on, PATCH /webhooks/{id} with { "status": "active" }. That clears the streak as well, because leaving it at 20 would switch the endpoint off again on its next single failure whatever you fixed. Events that fired while it was off are not resent.
Sending a test to a disabled endpoint is 409 webhook_endpoint_disabled.
Which addresses we will post to
A webhook endpoint must be a public https address. We check it when you register it and again before every delivery, and refuse to deliver to an address that is not public.
A delivery only ever goes to an address that passed the check.
| Refused | Why | Code |
|---|---|---|
| Anything but https in production | The body carries your project titles and the signature is over it. Plaintext hands both to every hop. | webhook_url_invalid |
| A username or a password in the URL | It would be logged, echoed in the delivery log and stored with the endpoint. | webhook_url_invalid |
| A fragment | A fragment is never sent on the wire, so one in a stored URL means you believe something is being delivered that is not. | webhook_url_invalid |
| A non default port in production | Webhooks are delivered only to public web servers on the standard port. | webhook_url_invalid |
| A hostname with no dot | A name with no dot is not a public address. | webhook_url_invalid |
| A URL over 2048 characters | The longest address accepted. | webhook_url_invalid |
| A name that resolves to a private, loopback, link local, carrier grade NAT or IPv4 mapped IPv6 address | Every resolved address must be public. One private answer among several is enough to refuse it. | webhook_url_blocked |
| A name that does not resolve at all | There is nothing to post to. | webhook_url_blocked |
| A host on the deployment's denylist | An operator has excluded that host and its subdomains. | webhook_host_denied |
HTTP/1.1 400 Bad Request
{
"error": {
"code": "webhook_url_blocked",
"message": "That webhook URL resolves to an address this API will not post to. Point it at a public address.",
"details": { "reason": "IPv6 loopback", "host": "example.com" },
"requestId": "4eb7b9f7-85c7-4f68-be3f-0a6b85265ad7"
}
}HTTP/1.1 400 Bad Request
{
"error": {
"code": "webhook_url_invalid",
"message": "That webhook URL is not one this API will post to. Use an https URL with no username, password or fragment.",
"details": { "reason": "bare_host" },
"requestId": "dd82496b-e3e5-4521-a347-e74d665d980b"
}
}details.reason names which rule it broke, so a form can point at the field. A URL that stops resolving to a public address after registration is not retried five more times: the delivery is settled immediately, the reason is recorded as blocked:<code>, and it counts against the failure streak.
Reading and changing endpoints
| Operation | What it does |
|---|---|
GET /webhooks | Every endpoint of the account, newest first, as a cursor page. Disabled ones are included. |
GET /webhooks/{id} | One endpoint, with status, failureStreak, disabledAt, disabledReason and lastDeliveryAt. |
PATCH /webhooks/{id} | Change the url, the events, the description or the status. A new url is fenced like a new registration. |
DELETE /webhooks/{id} | Remove it. Its pending deliveries go with it rather than arriving after you deleted it. |
POST /webhooks/{id}/test | Send a webhook.test through the real delivery path. |
GET /webhooks/{id}/deliveries | The delivery log for one endpoint: attempt, status, your responseStatus, responseMs and lastError. |