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"
  }'
JSON
{
  "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: false for 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.

JSON
{
  "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.

JSON
{
  "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.

JSON
{
  "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.

JSON
{
  "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 POST with a JSON body and a GetStarted-Signature header.
  • Respond with any 2xx status 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 id and skip ones you have seen.
  • Events can arrive out of order. A retried older event can land after a newer one. Compare createdAt with the last event you applied for that incorporation, or fetch the current state with GET/incorporations/{incorporationId}/status and 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:

HTTP
GetStarted-Signature: t=1790139757,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
  • t: the Unix time, in seconds, when the request was signed.
  • v1: a hex-encoded HMAC-SHA256 signature.

To verify:

  1. Split the header on , and each part on the first =. Take t and every v1.
  2. Build the signed payload: the t value exactly as sent, a ., then the raw request body bytes. In template form: ${t}.${rawBody}.
  3. Compute HMAC-SHA256 of the signed payload, keyed with your endpoint secret (the whole whsec_… string), and hex-encode it.
  4. Compare your result with each v1 using a constant-time comparison. Accept if any matches.
  5. Reject if the absolute difference between the current time and t is more than 300 seconds. This stops a captured request from being replayed later.

Return 400 when verification fails, and do not process the event.

Important

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.