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.
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.
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. |
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, 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:
POST https://connector.oomol.com/v1/saas/projects/{projectId}/webhooks
Authorization: Bearer <project-api-key>
Content-Type: application/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.
-
Create a webhook with
"scope": "routed". In Console, set Receives events from to Routed connections only. -
Pass the webhook ID as
webhookIdwhen the end user connects the account. Authorization links (POST /v1/saas/connected-accounts/link) and credential submissions such asPOST /v1/saas/connected-accounts/api-keyaccept it:{ "providerConfigId": "<provider-config-id>", "userId": "customer-1", "webhookId": "<routed-webhook-id>", "returnUri": "https://app.example.com/connections/done" } -
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. |
webhookIdmust be aroutedwebhook in the same project. Any other value returns 404webhook_not_found.- A connection started with
webhookIdsends itsconnection.succeededorconnection.failedevent to that webhook. - Reconnecting without
webhookIdkeeps the current routing. - Trigger subscriptions on a routed account deliver
trigger.messageevents to its routed webhook. - Deleting a routed webhook removes the routing from its accounts, and their events go to
projectwebhooks 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 isnullfor 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:
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.
{
"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 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 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. |