Skip to content

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.

bash
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
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"
  }
}
201. Executed.

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.export
A marketing video being cut
marketing.export.succeededmarketing.export.failedmarketing.export.cancelled
marketing.generation
A marketing project being generated
marketing.generation.succeededmarketing.generation.failedmarketing.generation.cancelled
marketing.source
A website scan
marketing.source.succeededmarketing.source.failedmarketing.source.cancelled
render
A walkthrough video
render.succeededrender.failedrender.cancelled
storyboard
A walkthrough plan
storyboard.succeededstoryboard.failedstoryboard.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
HTTP/1.1 202 Accepted

{
  "deliveryId": "3f2b0c1a-9d4e-4a61-b8c2-000000000015",
  "eventId": "3f2b0c1a-9d4e-4a61-b8c2-000000000016"
}
202. Executed.

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
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
}
200. Executed, showing the test delivery that had just succeeded.

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.

http
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"}
A real delivery, captured from a receiver. The secret and the ids are this page's.
HeaderWhat it is
Gogoscreen-Event-IdThe event. Stable across every attempt of one event, so this is the field to deduplicate on.
Gogoscreen-Event-TypeOne of the event types above. The same value as body.type.
Gogoscreen-Delivery-IdThis attempt's delivery log entry, which GET /webhooks/{id}/deliveries lists. Not a dedupe key.
Gogoscreen-Delivery-Attempt1 to 6.
Gogoscreen-Signaturet=<unix seconds>,v1=<hex>. See below.
NameTypeDescription
idstringThe event id. The same value as Gogoscreen-Event-Id.
typestringThe event type.
createdAtstringWhen the event was made, ISO 8601.
apiVersionstringWhich version of the API shaped data. "v1" for as long as /api/v1 is.
dataobjectThe 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.
json
{
  "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"
}
The body of the captured test delivery, pretty printed. Key order on the wire is not guaranteed, which is one more reason to verify the raw bytes.

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 t without forging the MAC. Reject anything more than 300 seconds out.
  • The compare is constant time, and the length is checked first because Node's timingSafeEqual throws on a length mismatch.
  • The body is bytes, not an object. Verify against the raw request body before anything parses it.
verify.js
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 };
Both verifiers were run against a delivery signed by the server's own signer.

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

PropertyValue
GuaranteeAt least once. The same event id can arrive more than once; dedupe on it.
Attempts6
Backoffexponential, 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.
SuccessAny 2xx, answered within 10 seconds. The socket timeout is the ceiling on one attempt.
RedirectsA 3xx is recorded as a failure and is never followed, because the target of a redirect has not passed the address checks below.
Response bodiesNever 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 agentGogoscreen-Webhooks/1.0
Auto disable20 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.

RefusedWhyCode
Anything but https in productionThe 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 URLIt would be logged, echoed in the delivery log and stored with the endpoint.webhook_url_invalid
A fragmentA 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 productionWebhooks are delivered only to public web servers on the standard port.webhook_url_invalid
A hostname with no dotA name with no dot is not a public address.webhook_url_invalid
A URL over 2048 charactersThe longest address accepted.webhook_url_invalid
A name that resolves to a private, loopback, link local, carrier grade NAT or IPv4 mapped IPv6 addressEvery 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 allThere is nothing to post to.webhook_url_blocked
A host on the deployment's denylistAn operator has excluded that host and its subdomains.webhook_host_denied
http
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"
  }
}
A public name whose DNS answers a loopback address. Executed.
http
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"
  }
}
A single label hostname. Executed.

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

OperationWhat it does
GET /webhooksEvery 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}/testSend a webhook.test through the real delivery path.
GET /webhooks/{id}/deliveriesThe delivery log for one endpoint: attempt, status, your responseStatus, responseMs and lastError.