---
author: OOMOL
author_url: https://oomol.com/about/
title: Receive Project Webhooks
description: Create a project webhook, verify signed deliveries, and handle
  connection and Trigger events from Connector for SaaS.
lang: en
canonical_url: https://oomol.com/docs/saas-webhooks/
markdown_url: https://oomol.com/docs/saas-webhooks.md
---

# Receive Project Webhooks

A project webhook sends Connector for SaaS events to an HTTPS endpoint on your backend. Use it to learn when an end user finishes or fails an account connection, and to receive events from [Trigger subscriptions](/docs/saas-triggers/).

Each project can have up to 3 webhooks. Every webhook chooses its event types and the account auth types it receives, and every delivery is signed with that webhook's signing secret. By default, a webhook receives events for every connected account in the project. To send one account's events to a separate receiver, [route the account to a dedicated webhook](#route-an-accounts-events-to-a-dedicated-webhook).

## Event types

| Event type | Sent when |
| --- | --- |
| `connection.succeeded` | An end user connects an account through an authorization link or a credential submission. |
| `connection.failed` | An account connection fails, for example when the user cancels authorization or the provider rejects the credential. |
| `trigger.message` | A Trigger subscription produces an event. See [Receive provider events with Triggers](/docs/saas-triggers/). |

`authTypes` limits a webhook to accounts with matching auth types: `oauth2`, `api_key`, or `custom_credential`. The filter applies to connection and Trigger events alike. For example, a webhook with `authTypes: ["oauth2"]` receives no events for API key accounts.

## Create a webhook

In [OOMOL Console](https://console.oomol.com/), open **Projects**, select the project, open **Webhooks**, and click **Create webhook**. Enter the HTTPS URL, select **Event types** and **Auth types**, and create the webhook.

Your backend can also create one with a Project API key:

```http
POST https://connector.oomol.com/v1/saas/projects/{projectId}/webhooks
Authorization: Bearer <project-api-key>
Content-Type: application/json
```

```json
{
  "url": "https://api.example.com/oomol/webhooks",
  "eventTypes": ["connection.succeeded", "connection.failed", "trigger.message"],
  "authTypes": ["oauth2", "api_key", "custom_credential"],
  "description": "Production receiver"
}
```

| Field | Usage |
| --- | --- |
| `url` | Required: a public HTTPS URL. |
| `eventTypes` | Required: one or more event types from the table above. |
| `authTypes` | Required: one or more account auth types whose events this webhook receives. |
| `scope` | Optional: `project` (default) receives events for every connected account that is not routed to a webhook. `routed` receives only events for accounts routed to it. It cannot be changed after creation. |
| `eventVersion` | Optional: the payload version pinned for this webhook. The current version is `1`. |
| `description` | Optional: a note of up to 200 characters. |

The response includes `signingSecret`, shaped like `whsec_…`. It is returned only once. Store it in your backend secret manager before closing the Console dialog or discarding the response; later reads show only `signingSecretPrefix`.

Use `PATCH /v1/saas/projects/{projectId}/webhooks/{webhookId}` to change the URL, event types, auth types, description, or `status` (`active` or `disabled`). A disabled webhook receives no deliveries. `scope` cannot be changed; create another webhook instead.

## Route an account's events to a dedicated webhook

Route a connected account when its events belong on a separate receiver, for example a staging endpoint for a test account, or a receiver run by one of your customers.

1. Create a webhook with `"scope": "routed"`. In Console, set **Receives events from** to **Routed connections only**.
2. Pass the webhook ID as `webhookId` when the end user connects the account. Authorization links (`POST /v1/saas/connected-accounts/link`) and credential submissions such as `POST /v1/saas/connected-accounts/api-key` accept it:

   ```json
   {
     "providerConfigId": "<provider-config-id>",
     "userId": "customer-1",
     "webhookId": "<routed-webhook-id>",
     "returnUri": "https://app.example.com/connections/done"
   }
   ```

3. To route an account that is already connected, or to undo the routing, send `PATCH /v1/saas/projects/{projectId}/connected-accounts/{connectedAccountId}` with `{"webhookId": "<routed-webhook-id>"}` or `{"webhookId": null}`. In Console, open **Connected accounts**, edit the connection, and choose **Event delivery**.

| Account | Where its events go |
| --- | --- |
| Not routed | Every active `project` webhook whose `eventTypes` and `authTypes` match. Routed webhooks receive none of its events. |
| Routed | Only its routed webhook, and only when that webhook is `active` and its `eventTypes` and `authTypes` match. Otherwise the event is not delivered. It never falls back to `project` webhooks. |

- `webhookId` must be a `routed` webhook in the same project. Any other value returns 404 `webhook_not_found`.
- A connection started with `webhookId` sends its `connection.succeeded` or `connection.failed` event to that webhook.
- Reconnecting without `webhookId` keeps the current routing.
- [Trigger subscriptions](/docs/saas-triggers/) on a routed account deliver `trigger.message` events to its routed webhook.
- Deleting a routed webhook removes the routing from its accounts, and their events go to `project` webhooks again.
- Routing is decided when an event is recorded. Deliveries that are already queued or retrying keep their webhook after the routing changes. If that webhook is disabled or deleted, they stop and are not sent to the new webhook.
- Connected account responses include `webhookId`, which is `null` for accounts that are not routed.

## Verify each delivery

Every delivery is an HTTPS `POST` with a JSON body and these headers:

| Header | Value |
| --- | --- |
| `x-oo-signature` | `t=<unix seconds>,v1=<hex signature>` |
| `x-oo-event` | The event type, for example `trigger.message`. |
| `x-oo-event-id` | The envelope `id`. Retries of the same event keep this value. |
| `x-oo-webhook-id` | The webhook that received the delivery. |
| `x-oo-delivery-id` | The delivery ID shown in delivery records. |

The signature is an HMAC-SHA256 of `<t>.<raw request body>`, keyed with the signing secret and encoded as hex. Verify it against the raw body before parsing JSON:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

const toleranceSeconds = 300;

export function verifyOomolWebhook(secret: string, rawBody: string, header: string | null) {
  if (!header) return false;
  let timestamp = Number.NaN;
  const signatures: string[] = [];
  for (const part of header.split(",")) {
    const [key, value] = part.split("=", 2);
    if (key === "t") timestamp = Number(value);
    if (key === "v1" && value) signatures.push(value);
  }
  const now = Math.floor(Date.now() / 1000);
  if (!Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceSeconds) return false;

  const expected = createHmac("sha256", secret).update(`${timestamp}.${rawBody}`, "utf8").digest();
  return signatures.some((signature) => {
    const actual = Buffer.from(signature, "hex");
    return actual.length === expected.length && timingSafeEqual(actual, expected);
  });
}
```

Reject the request when verification fails. Accept it when any `v1` value matches: during a secret rotation, the header carries one signature for each active secret.

## Read the event envelope

All events share one envelope. Use `id` to deduplicate; the same event keeps the same `id` across retries and across webhooks.

```json
{
  "id": "evt_0199d1f4-7b2a-7c3e-9a51-3f0f2b8c1d20",
  "type": "connection.succeeded",
  "version": 1,
  "createdAt": 1800000000000,
  "data": {
    "connectionRequestId": "0199d1f3-1c4e-7a90-b2d3-5e6f7a8b9c0d",
    "externalUserId": "customer-1",
    "account": {
      "id": "0199d1f4-2e5f-7b61-8c72-9d0e1f2a3b4c",
      "alias": "work",
      "status": "active",
      "authType": "oauth2"
    },
    "app": {
      "service": "gmail",
      "providerConfigId": "0199d1a0-5f6e-7d8c-9b0a-1c2d3e4f5a6b",
      "slug": "gmail",
      "displayName": "Gmail"
    }
  }
}
```

`createdAt` is a Unix timestamp in milliseconds. `externalUserId` is the `userId` your backend sent to Connector. For `connection.failed`, `account.id` and `account.status` are `null`, and `data.error` contains `code` and `message`. `connectionRequestId` is `null` for connections created without an authorization request, such as credential submissions.

Before you trust a `connection.succeeded` event, match it to a connection your backend started, then save the account binding. For authorization requests, match `externalUserId` and `connectionRequestId` against the request your backend created. For credential submissions, `connectionRequestId` is `null`; match `externalUserId` and `account.id` against the account returned by the submit call, which your backend already has when that call returns. The [`trigger.message` payload](/docs/saas-triggers/#receive-events) is described in the Trigger guide.

## Respond and retry

Return a 2xx status after you have verified and stored the event. Move slow work to a background job; delivery requests time out after 10 seconds, and redirects are not followed.

| Endpoint result | What Connector does |
| --- | --- |
| 2xx | Marks the delivery as delivered. |
| 408, 425, 429, 5xx, timeout, or network error | Retries the delivery. |
| Any other 4xx, or the webhook is disabled or deleted | Stops delivering this event to this webhook. |

Retries wait 5 seconds, 30 seconds, 2 minutes, 10 minutes, 30 minutes, 1 hour, and 2 hours, for at most 8 attempts over about 3.7 hours. Every wait except the first varies by up to 20%. If your endpoint is unavailable for that whole window, the delivery ends as `failed` and the event is not sent again; check [delivery records](#test-inspect-and-rotate) to find it. If your endpoint stores an event but the acknowledgment is lost, the event can arrive again with the same `id`.

## Test, inspect, and rotate

| Task | In Console | With the API |
| --- | --- | --- |
| Send a sample `connection.succeeded` event | **Send test** | `POST /v1/saas/projects/{projectId}/webhooks/{webhookId}/test` |
| View recent attempts | **Deliveries** | `GET /v1/saas/projects/{projectId}/webhooks/{webhookId}/deliveries` |
| Replace the signing secret | **Rotate secret** | `POST /v1/saas/projects/{projectId}/webhooks/{webhookId}/rotate-secret` |

Delivery records cover the most recent 3 days. Each attempt shows its status (`delivered`, `retrying`, or `failed`), attempt number, HTTP status, and error message.

Rotation returns a new `signingSecret` once. The previous secret keeps signing deliveries for 24 hours by default, so deploy the new secret to your receiver during that window. Set `previousSecretExpiresInSeconds` to choose another grace period, or to `0` to stop using the previous secret immediately. Rotating again ends any earlier grace period.

## Troubleshooting

| Symptom | What to check |
| --- | --- |
| Signatures never match | Verify the raw body before any JSON parsing or re-serialization, and check that the receiver uses this webhook's secret. |
| Signatures fail after a rotation | Deploy the new secret before the previous secret's grace period ends. `previousSigningSecretExpiresAt` shows when it ends. |
| Events arrive twice | Deduplicate by the envelope `id` after verifying the signature. The signature does not cover `x-oo-event-id`, so do not deduplicate by that header alone. |
| No events for some accounts | Check that `authTypes` includes the account's auth type and that the webhook is `active`. If the account has a `webhookId`, check that routed webhook instead; `project` webhooks do not receive its events. |
| Deliveries show `failed` after one attempt | The endpoint returned a non-retryable 4xx. Check authentication middleware and routing on your receiver. |
