Webhooks and signatures
Receive incorporation events, handle retries and duplicates, and verify that each request came from GetStarted.
GetStarted sends an HTTPS POST to your endpoint whenever an incorporation changes status, gets an issue, has an issue resolved, or completes. Each request is signed with your endpoint's secret. Verify the signature, return 2xx quickly, and process the event afterwards.
Create an endpoint
POST/webhooks
curl https://api.getstarted.ae/v1/webhooks \
-H "Authorization: Bearer gs_test_xxx" \
-H "Content-Type: application/json" \
-d '{
"url": "https://api.northwindlabs.com/hooks/getstarted",
"events": [
"incorporation.status_changed",
"incorporation.issue_created",
"incorporation.issue_resolved",
"incorporation.completed"
],
"description": "Production incorporation events"
}'{
"id": "whk_01J9ZKJ3GX7N2S",
"object": "webhook",
"url": "https://api.northwindlabs.com/hooks/getstarted",
"events": [
"incorporation.status_changed",
"incorporation.issue_created",
"incorporation.issue_resolved",
"incorporation.completed"
],
"status": "ENABLED",
"description": "Production incorporation events",
"livemode": false,
"createdAt": "2026-09-20T09:10:05Z",
"secret": "whsec_7Xk2pQ9rT4vW8yZ1bN5mC3dF6gH0jL"
}The secret is returned in this response and never again. Put it in your secrets manager straight away. If it is lost, delete the endpoint with DEL/webhooks/{id} and create a new one.
Pass ["*"] as events to receive every event type, including ones added later. Endpoints belong to one environment: create one with a gs_test_ key for the sandbox and another with a gs_live_ key for live, each with its own secret.
Events
Every event has the same envelope:
id: unique per event,evt_…. Use it to drop duplicates.object: always"event".type: what happened.livemode:falsefor sandbox events.createdAt: when the change happened.data: the details, which vary by type.
incorporation.status_changed
Sent whenever status or stage changes. Both the previous and new values are included, so you can tell a stage move within IN_PROGRESS from a status change.
{
"id": "evt_01J9ZKK8HY8P3T",
"object": "event",
"type": "incorporation.status_changed",
"livemode": false,
"createdAt": "2026-09-23T06:42:37Z",
"data": {
"incorporationId": "inc_01J9ZK7C3VYB5N",
"customerId": "cus_01J9ZK4R7Q2M8X",
"previousStatus": "IN_PROGRESS",
"status": "IN_PROGRESS",
"previousStage": "compliance_review",
"stage": "name_reservation"
}
}incorporation.issue_created
Sent when processing needs something from you. The incorporation moves to ACTION_REQUIRED until the issue is resolved. data.issue is the full issue, and its path tells you what to fix.
{
"id": "evt_01J9ZKP2MA4R6V",
"object": "event",
"type": "incorporation.issue_created",
"livemode": false,
"createdAt": "2026-09-22T10:02:11Z",
"data": {
"incorporationId": "inc_01J9ZK7C3VYB5N",
"customerId": "cus_01J9ZK4R7Q2M8X",
"issue": {
"id": "iss_01J9ZKH6FW6M1R",
"object": "issue",
"incorporationId": "inc_01J9ZK7C3VYB5N",
"status": "OPEN",
"category": "DOCUMENT",
"path": "participants.par_01J9ZKE7LB2H6J.documents.PASSPORT_SCAN",
"message": "The passport scan is cropped. Upload a scan that shows all four corners of the photo page.",
"createdAt": "2026-09-22T10:02:11Z",
"resolvedAt": null
}
}
}incorporation.issue_resolved
Sent when the data or document at the issue's path has been fixed and accepted. Once no issues are open, processing resumes and you receive a status change.
{
"id": "evt_01J9ZKQ7NB5S8W",
"object": "event",
"type": "incorporation.issue_resolved",
"livemode": false,
"createdAt": "2026-09-22T14:37:50Z",
"data": {
"incorporationId": "inc_01J9ZK7C3VYB5N",
"customerId": "cus_01J9ZK4R7Q2M8X",
"issue": {
"id": "iss_01J9ZKH6FW6M1R",
"object": "issue",
"incorporationId": "inc_01J9ZK7C3VYB5N",
"status": "RESOLVED",
"category": "DOCUMENT",
"path": "participants.par_01J9ZKE7LB2H6J.documents.PASSPORT_SCAN",
"message": "The passport scan is cropped. Upload a scan that shows all four corners of the photo page.",
"createdAt": "2026-09-22T10:02:11Z",
"resolvedAt": "2026-09-22T14:37:50Z"
}
}
}incorporation.completed
Sent once, when the company is registered. businessEntityId is the new company. Fetch its registrations, documents and government identifiers from the business entity endpoints.
{
"id": "evt_01J9ZKR3PC6T9X",
"object": "event",
"type": "incorporation.completed",
"livemode": false,
"createdAt": "2026-10-02T03:10:00Z",
"data": {
"incorporationId": "inc_01J9ZK7C3VYB5N",
"customerId": "cus_01J9ZK4R7Q2M8X",
"businessEntityId": "ent_01J9ZKS8QD7V2Y",
"status": "COMPLETED",
"stage": "incorporated",
"completedAt": "2026-10-02T03:10:00Z"
}
}Delivery
- Each event is a
POSTwith a JSON body and aGetStarted-Signatureheader. - Respond with any
2xxstatus within 10 seconds. Anything else, including a timeout, counts as a failure. - Failed deliveries are retried with increasing delays for up to 3 days.
- Events can arrive more than once. Store each processed
idand skip ones you have seen. - Events can arrive out of order. A retried older event can land after a newer one. Compare
createdAtwith the last event you applied for that incorporation, or fetch the current state with GET/incorporations/{incorporationId}/statusand treat that as the truth.
Acknowledge first, work later. Verify the signature, record the event, return 200, and do anything slow (database writes across services, emails, API calls) in a background job. A handler that calls back into the API before responding risks the 10-second limit and a retry.
Verify signatures
Anyone can send a POST to your URL. The signature proves a request came from GetStarted and was not altered. The header looks like this:
GetStarted-Signature: t=1790139757,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdt: the Unix time, in seconds, when the request was signed.v1: a hex-encoded HMAC-SHA256 signature.
To verify:
- Split the header on
,and each part on the first=. Taketand everyv1. - Build the signed payload: the
tvalue exactly as sent, a., then the raw request body bytes. In template form:${t}.${rawBody}. - Compute HMAC-SHA256 of the signed payload, keyed with your endpoint secret (the whole
whsec_…string), and hex-encode it. - Compare your result with each
v1using a constant-time comparison. Accept if any matches. - Reject if the absolute difference between the current time and
tis more than 300 seconds. This stops a captured request from being replayed later.
Return 400 when verification fails, and do not process the event.
Verify against the raw body bytes exactly as received. If a framework parses the JSON first and you re-serialize it, spacing and key order can change and the signature will not match. Capture the raw body on the webhook route before any JSON middleware touches it.
// Node 18+, Express 4 or 5
import crypto from "node:crypto";
import express from "express";
const WEBHOOK_SECRET = process.env.GETSTARTED_WEBHOOK_SECRET; // whsec_...
const TOLERANCE_SECONDS = 300;
export function verifyGetStartedSignature(rawBody, header, secret, toleranceSeconds = TOLERANCE_SECONDS) {
if (typeof header !== "string" || header.length === 0) {
throw new Error("Missing GetStarted-Signature header");
}
let timestamp = null;
const signatures = [];
for (const part of header.split(",")) {
const i = part.indexOf("=");
if (i === -1) continue;
const key = part.slice(0, i).trim();
const value = part.slice(i + 1).trim();
if (key === "t") timestamp = value;
if (key === "v1") signatures.push(value);
}
if (timestamp === null || !/^\d+$/.test(timestamp) || signatures.length === 0) {
throw new Error("Malformed GetStarted-Signature header");
}
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - Number(timestamp)) > toleranceSeconds) {
throw new Error("Signature timestamp outside tolerance");
}
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.`)
.update(rawBody) // Buffer, exactly as received
.digest();
const matched = signatures.some((sig) => {
const received = Buffer.from(sig, "hex");
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
});
if (!matched) throw new Error("Signature mismatch");
return JSON.parse(rawBody.toString("utf8"));
}
const app = express();
const processed = new Set(); // use your database in production
// express.raw keeps req.body as a Buffer on this route only.
app.post("/hooks/getstarted", express.raw({ type: "application/json" }), (req, res) => {
let event;
try {
event = verifyGetStartedSignature(req.body, req.get("GetStarted-Signature"), WEBHOOK_SECRET);
} catch (err) {
return res.status(400).send(err.message);
}
if (processed.has(event.id)) return res.sendStatus(200);
processed.add(event.id);
res.sendStatus(200);
// Hand off to a queue or background job here.
setImmediate(() => handleEvent(event));
});
function handleEvent(event) {
switch (event.type) {
case "incorporation.status_changed":
case "incorporation.issue_created":
case "incorporation.issue_resolved":
case "incorporation.completed":
console.log(event.type, event.data.incorporationId);
break;
}
}
app.listen(3000);Testing
Point a sandbox endpoint at your handler and submit a sandbox incorporation. It moves through the stages on a simulated timeline and sends the same events as live, signed the same way, with livemode: false. Your handler should use the secret for the environment the event came from.