Skip to Content
BuildEvents and webhooks

Build event-driven integrations

Use Arcade eventing when your application or agent should react after something happens. This guide follows one fact from its producer, through event history, to a signed request at your receiver.

The eventing model

The producer side is separate from the delivery side:

TEXT
trigger instance ─┐ schedule ─────────┼─> Arcade event ─> webhook subscription ─> webhook delivery ─> receiver provider ingress ─┘
TermResponsibilitySourceDestination
Arcade eventStoreTrigger instance, schedule, or provider ingressProject history
Trigger typeConfigureToolkit declarationTrigger instance
Trigger instanceProduceConnected-account observationArcade event
ScheduleProduceTime ruleArcade event
Provider ingressProduceVerified provider callbackArcade event
Webhook subscriptionRouteMatching Arcade eventWebhook delivery
Webhook deliveryDeliverWebhook subscriptionConfigured receiver

A trigger type is a reusable toolkit declaration. A trigger instance binds that type to one , connection, and filter configuration. Poll trigger instances, such as Gmail, ask the provider for changes on a declared cadence. Provider ingress instead begins realtime processing when Arcade receives a verified provider callback; it does not run an Arcade polling loop.

Provider ingress setup is specific to a realtime provider. Find supported providers in Integrations; do not configure provider ingress as an outgoing webhook.

A webhook subscription is outgoing: it selects Arcade events and routes them to your URL. A webhook delivery is one event-subscription pairing and owns its attempt history. Neither is an OAuth callback URL.

Choose your deployment origin

The origin changes by deployment mode. The scoped REST path does not.

ModeAPI originDashboard origin
Arcade Cloudhttps://api.arcade.devhttps://app.arcade.dev
Customer-managed$ARCADE_ENGINE_URL$ARCADE_ENGINE_URL/dashboard
Localhttp://localhost:9099http://localhost:9099/dashboard

The examples default to Arcade Cloud. For customer-managed or local Arcade, replace the API origin with the value in the table.

Terminal
export ARCADE_API_ORIGIN="https://api.arcade.dev" export ARCADE_ORG_ID="your-org-id" export ARCADE_PROJECT_ID="your-project-id" export ARCADE_API_KEY="your-api-key" export RECEIVER_URL="https://receiver.example.com/events" export SCOPE="$ARCADE_API_ORIGIN/v1/orgs/$ARCADE_ORG_ID/projects/$ARCADE_PROJECT_ID"

Every REST request below sends Authorization: Bearer $ARCADE_API_KEY and stays under /v1/orgs/$ARCADE_ORG_ID/projects/$ARCADE_PROJECT_ID.

Credentials for one organization and cannot access another scope’s resources. Keep the organization, project, and from the same selected project. Do not accept organization or project authority from an event payload.

The Arcade API reference owns the complete request and response schemas. This guide keeps only the fields needed for the two journeys.

Verify the receiver before processing

Arcade returns a webhook signing secret only when the subscription is created or its secret is rotated. It uses the whsec_ prefix followed by padded standard base64. Store it as a secret and verify the exact raw request body before parsing JSON or checking for duplicates.

The following Python 3.10+ example has no framework dependency. Call receive from your HTTP handler with the unchanged raw request bytes and lowercase or mixed-case headers, then use its return value as the HTTP response status.

Python
receiver.py
import base64 import hashlib import hmac import json import logging import sqlite3 import time from collections.abc import Callable, Mapping TOLERANCE_SECONDS = 300 SECRET_FORMAT_ERROR = ( "webhook secrets must use whsec_ followed by padded standard base64" ) WebhookSecrets = list[str] | tuple[str, ...] logger = logging.getLogger(__name__) class VerificationError(Exception): pass class ConfigurationError(Exception): pass def verify_request( body: bytes, headers: Mapping[str, str], secrets: WebhookSecrets, now: int | None = None, ) -> tuple[dict, str]: if not isinstance(secrets, (list, tuple)) or any( not isinstance(secret, str) for secret in secrets ): raise ConfigurationError("webhook secrets must be a list or tuple of strings") normalized = {key.lower(): value for key, value in headers.items()} try: delivery_id = normalized["webhook-id"] timestamp_text = normalized["webhook-timestamp"] supplied = normalized["webhook-signature"].split() except KeyError as error: raise VerificationError(f"missing {error.args[0]}") from error try: timestamp = int(timestamp_text) except ValueError as error: raise VerificationError("invalid webhook-timestamp") from error verification_time = int(time.time()) if now is None else now if abs(verification_time - timestamp) > TOLERANCE_SECONDS: raise VerificationError("webhook-timestamp outside tolerance") signed = ( delivery_id.encode() + b"." + timestamp_text.encode() + b"." + body ) keys: list[bytes] = [] for secret in secrets: if not secret.startswith("whsec_"): raise ConfigurationError(SECRET_FORMAT_ERROR) try: key = base64.b64decode(secret.removeprefix("whsec_"), validate=True) except ValueError as error: raise ConfigurationError(SECRET_FORMAT_ERROR) from error if not key: raise ConfigurationError(SECRET_FORMAT_ERROR) keys.append(key) if not keys: raise ConfigurationError("no webhook secrets configured") matched = False for key in keys: digest = hmac.new(key, signed, hashlib.sha256).digest() expected = b"v1," + base64.b64encode(digest) for candidate in supplied: try: encoded = candidate.encode("ascii") except UnicodeEncodeError: continue matched |= hmac.compare_digest(expected, encoded) if not matched: raise VerificationError("invalid webhook-signature") try: event = json.loads(body) except json.JSONDecodeError as error: raise VerificationError("invalid JSON") from error if not isinstance(event, dict): raise VerificationError("event must be a JSON object") return event, delivery_id class SQLiteInbox: """A durable idempotency inbox for one receiver process.""" def __init__(self, path: str): self.path = path connection = sqlite3.connect(path, timeout=30, isolation_level=None) try: connection.execute( """CREATE TABLE IF NOT EXISTS webhook_inbox ( webhook_id TEXT PRIMARY KEY, received_at INTEGER NOT NULL )""" ) connection.commit() finally: connection.close() def handle( self, delivery_id: str, event: dict, handler: Callable[[sqlite3.Connection, dict], None], ) -> bool: connection = sqlite3.connect(self.path, timeout=30, isolation_level=None) try: connection.execute("BEGIN IMMEDIATE") inserted = connection.execute( """INSERT OR IGNORE INTO webhook_inbox(webhook_id, received_at) VALUES (?, ?)""", (delivery_id, int(time.time())), ).rowcount if inserted == 0: connection.commit() return False handler(connection, event) connection.commit() return True except Exception: connection.rollback() raise finally: connection.close() def receive( body: bytes, headers: Mapping[str, str], subscription_secrets: WebhookSecrets, inbox: SQLiteInbox, authorize: Callable[[dict], bool], handler: Callable[[sqlite3.Connection, dict], None], now: int | None = None, ) -> int: try: event, delivery_id = verify_request(body, headers, subscription_secrets, now) except VerificationError: return 400 except ConfigurationError: return 500 try: # Build this callback from server-side subscription configuration. Do not # accept event types or tenant IDs merely because they appear in the payload. if not authorize(event): return 403 inbox.handle(delivery_id, event, handler) except Exception: logger.exception("webhook handler failed") return 500 return 204

The inbox claim and your business writes must share one transaction. If the handler fails, both roll back and the non-2xx response lets Arcade retry. A valid duplicate returns 204 without rerunning the handler. During secret rotation, pass both active secrets; remove the retired secret after the grace period.

Resolve one subscription from your HTTP route before calling receive, and pass only that subscription’s current and previous secrets as subscription_secrets. Build authorize from that server-side subscription: allow its configured event types and, when the event schema carries tenant identifiers, compare them with the route’s organization and . The payload itself is not authority. Arcade event envelopes use type, timestamp, and data:

JSON
{ "type": "tenant.audit_recorded", "timestamp": "2026-08-28T21:00:00Z", "data": {"organization_id":"org_123","project_id":"proj_123"} }

An authorization rejection returns 403, so Arcade retries and eventually records a dead delivery; use that behavior to surface a mis-scoped subscription rather than silently dropping it. On an otherwise-valid request, any malformed current or previous secret makes the receiver return 500, including during rotation. Configure a request-body size limit in the HTTP framework before reading the raw body.

Use a persistent store in production. Keep each recorded webhook-id for at least Arcade’s configured event-retention period (90 days by default), plus operational margin. Manual retry and recovery reuse the original delivery ID after automatic attempts end, so sizing the inbox only to the 27-hour automatic retry span can repeat business side effects.

The sample records received_at for that cleanup policy but does not schedule the cleanup job for you. SQLite serializes these write transactions, so keep the handler’s transactional work short and local; use a concurrent durable inbox for production receivers with parallel or network-bound work.

Recover expired connected accounts

Subscribe to connection lifecycle events when your app should tell a that an OAuth connection needs attention:

  • connected_account.created—the connection became active for the first time.
  • connected_account.expired—Arcade can no longer use the connection.
  • connected_account.reconnected—reauthorization restored an expired connection.

Create the -scoped subscription:

Terminal
curl --fail-with-body --silent --show-error \ --request POST "$SCOPE/webhooks" \ --header "Authorization: Bearer $ARCADE_API_KEY" \ --header "Content-Type: application/json" \ --data "{\"url\":\"$RECEIVER_URL\",\"event_types\":[\"connected_account.expired\"]}"

Arcade sends the Standard Webhooks envelope. Verify the Standard Webhooks signature against the raw body before reading it:

JSON
{ "type": "connected_account.expired", "timestamp": "2026-08-28T21:00:00Z", "data": { "organization_id": "org_123", "project_id": "proj_123", "user_id": "user@example.com", "provider_id": "google", "connection_id": "ac_123", "status": "expired", "reason": "refresh_failed" } }

All three events identify the organization_id, user_id, provider_id, connection_id, and status. -bound events also include project_id. Only connected_account.expired includes reason:

  • no_refresh_token means the access token expired and no refresh token was available.
  • refresh_failed means the provider permanently rejected the refresh grant.

Each lifecycle event can originate from a -bound or organization-bound connection. The public subscription API is project-scoped, so it delivers the project-bound events in the preceding example. Organization-bound lifecycle events omit project_id and are not delivered to a project subscription.

In the Dashboard, open the expired connected , and choose Reconnect. The same action is available through POST /v1/orgs/{org_id}/projects/{project_id}/auth/authorize:

Terminal
curl --fail-with-body --silent --show-error \ --request POST "$SCOPE/auth/authorize" \ --header "Authorization: Bearer $ARCADE_API_KEY" \ --header "Content-Type: application/json" \ --data '{ "user_id":"user@example.com", "auth_requirement":{ "provider_id":"google", "provider_type":"oauth2", "oauth2":{"scopes":["https://www.googleapis.com/auth/gmail.readonly"]} } }'

Open the returned authorization URL. A successful flow restores the same connection and emits connected_account.reconnected. Triggers pinned to it become healthy again. Use the scopes on the connected- record; Dashboard does this for you. Arcade also applies scopes configured on the provider. A reconnected event reports the OAuth state change, but normal scope checks still apply if the provider returns a reduced grant.

Engine does not observe -managed OAuth refresh outcomes, so MCP-managed OAuth connections do not emit this lifecycle or use the standard OAuth Reconnect action.

Try a scheduled event

A schedule is a configurable producer; every fire creates a separate retained Arcade event with its own delivery history. Use an interval for this check.

Create the outgoing webhook

Open the selected ’s Webhooks page, create a URL endpoint for your receiver, and set Events to send to demo.follow_up. Copy the signing secret when it appears.

Create the schedule

Open Schedules, create Demo follow-up, choose an interval of 60 seconds, set the event type to demo.follow_up, and use this payload:

JSON
{"customer_id":"demo-123","action":"follow_up"}

Record the schedule ID and Next fire time.

Inspect the event and delivery

After the due time, poll Events for up to 120 seconds and select the row whose source is that schedule. The event detail shows the subscription, delivery ID, status, and attempts. Confirm the delivery succeeds and your receiver records a signature-valid request with the same webhook-id.

Clean up

Delete the demo schedule and webhook subscription. Return to Events and confirm the event is still present.

Try a filtered Gmail trigger

The gmail.message.received trigger declares a 60-second polling interval. Its optional filters are subject_contains, from, and to; to matches both To and Cc recipients. Filters inspect message metadata, not message bodies.

Before starting, connect the Gmail for the who owns the trigger. Create a webhook subscription for gmail.message.received and keep the receiver serving. For REST, use the webhook request above with gmail.message.received as its event type, then set WEBHOOK_ID to the returned ID.

Create the trigger

Open Triggers, choose Email received, and choose Myself for an initial test. Enter a run-unique value such as CUSTOMER-20260827-1048 in Subject contains, then create the trigger.

Wait for its first successful poll before sending the test messages. Send one message whose subject contains the token and one whose subject does not.

Inspect the result

Open the trigger’s View delivery history sheet. After a completed poll newer than both messages, the matching Gmail message ID appears in an event and the non-matching ID does not. Open the event to confirm its linked delivery succeeded, then check the same webhook-id at your receiver.

Clean up

Delete the trigger and its demo webhook subscription. The emitted event remains in Events until event retention removes it.

Operate each resource

Every route below is relative to the -scoped $SCOPE. The Dashboard uses the same public project resources. Recovery and replay belong to the webhook subscription: recovery requeues existing dead deliveries; replay creates deliveries for retained matching events that subscription never received.

ResourceDashboardREST API
Trigger instanceInspect, enable, disable, deleteGET /triggers/{trigger_id}, PATCH /triggers/{trigger_id}, DELETE /triggers/{trigger_id}, GET /triggers/{trigger_id}/events, GET /triggers/{trigger_id}/events/{event_id}
ScheduleInspect, enable, disable, deleteGET /schedules/{schedule_id}, PATCH /schedules/{schedule_id}, DELETE /schedules/{schedule_id}
Arcade eventList and inspect delivery tracesGET /events, GET /events/{event_id}
Webhook subscriptionInspect, enable, disable, rotate signing secret, delete, recover, replayGET /webhooks/{webhook_id}, PATCH /webhooks/{webhook_id}, POST /webhooks/{webhook_id}/rotate_secret, DELETE /webhooks/{webhook_id}, POST /webhooks/{webhook_id}/recover_deliveries, POST /webhooks/{webhook_id}/replay_missing
Webhook deliveryInspect and retry a dead deliveryGET /webhooks/{webhook_id}/deliveries/{delivery_id}, POST /webhooks/{webhook_id}/deliveries/{delivery_id}/retry

Deleting a trigger, schedule, or webhook subscription does not delete events already retained in history.

Recovery and replay take an RFC 3339 since timestamp in the JSON body:

Terminal
curl --fail-with-body --silent --show-error \ --request POST "$SCOPE/webhooks/$WEBHOOK_ID/recover_deliveries" \ --header "Authorization: Bearer $ARCADE_API_KEY" \ --header "Content-Type: application/json" \ --data '{"since":"2026-08-28T00:00:00Z"}' curl --fail-with-body --silent --show-error \ --request POST "$SCOPE/webhooks/$WEBHOOK_ID/replay_missing" \ --header "Authorization: Bearer $ARCADE_API_KEY" \ --header "Content-Type: application/json" \ --data '{"since":"2026-08-28T00:00:00Z"}'

Reliability boundaries

  • Schedule identity: Arcade publishes one Arcade event per scheduled fire, keyed by schedule ID and scheduled fire time. A schedule and the events it publishes have separate lifecycles.
  • Schedule timing: A due schedule is published no later than 60 seconds after its due time. The local 120-second delivery observation in this guide is a readiness check, not a production delivery-latency guarantee.
  • Delivery: Outgoing webhook delivery is at-least-once. Arcade makes 8 attempts: immediately, then after 5 seconds, 5 minutes, 30 minutes, 2 hours, 5 hours, 10 hours, and 10 hours. The configured delay from attempt 1 through attempt 8 totals 27 hours, 35 minutes, and 5 seconds.
  • Identity: One delivery keeps the same webhook-id across automatic retries, manual retry, and recovery. That value is also the {delivery_id} used by the delivery detail and retry APIs. Different events, and one event delivered through different subscriptions, receive different IDs; if two subscriptions share a receiver, deduplicate each delivery independently.
  • Signatures: Arcade signs every retry with a fresh webhook-timestamp, so a short receiver tolerance remains compatible with the full retry schedule. Verify the raw body, webhook-id, and webhook-timestamp before deduplication. The sample receiver chooses a 300-second clock-skew tolerance: it accepts timestamps through 300 seconds in either direction and rejects timestamps 301 seconds away.
  • Retention: events are retained for 90 days by default. An event exactly at the retention cutoff remains replayable; an older event is unavailable for replay.
  • Recovery and replay: since is inclusive. Recovery selects existing failed deliveries at or after the boundary. Replay selects retained matching events at or after the boundary that were never delivered to that subscription.

When a delivery is dead, retry that delivery. When several existing deliveries failed, recover them from a chosen time. When a subscription was absent or disabled, replay retained missing events from a chosen time.

Last updated on