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:
trigger instance ─┐
schedule ─────────┼─> Arcade event ─> webhook subscription ─> webhook delivery ─> receiver
provider ingress ─┘| Term | Responsibility | Source | Destination |
|---|---|---|---|
| Arcade event | Store | Trigger instance, schedule, or provider ingress | Project history |
| Trigger type | Configure | Toolkit declaration | Trigger instance |
| Trigger instance | Produce | Connected-account observation | Arcade event |
| Schedule | Produce | Time rule | Arcade event |
| Provider ingress | Produce | Verified provider callback | Arcade event |
| Webhook subscription | Route | Matching Arcade event | Webhook delivery |
| Webhook delivery | Deliver | Webhook subscription | Configured 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.
| Mode | API origin | Dashboard origin |
|---|---|---|
| Arcade Cloud | https://api.arcade.dev | https://app.arcade.dev |
| Customer-managed | $ARCADE_ENGINE_URL | $ARCADE_ENGINE_URL/dashboard |
| Local | http://localhost:9099 | http://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.
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.
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 204The 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:
{
"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.
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.
Dashboard
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:
{"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.
Dashboard
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.
| Resource | Dashboard | REST API |
|---|---|---|
| Trigger instance | Inspect, enable, disable, delete | GET /triggers/{trigger_id}, PATCH /triggers/{trigger_id}, DELETE /triggers/{trigger_id}, GET /triggers/{trigger_id}/events, GET /triggers/{trigger_id}/events/{event_id} |
| Schedule | Inspect, enable, disable, delete | GET /schedules/{schedule_id}, PATCH /schedules/{schedule_id}, DELETE /schedules/{schedule_id} |
| Arcade event | List and inspect delivery traces | GET /events, GET /events/{event_id} |
| Webhook subscription | Inspect, enable, disable, rotate signing secret, delete, recover, replay | GET /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 delivery | Inspect and retry a dead delivery | GET /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:
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-idacross 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, andwebhook-timestampbefore 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:
sinceis 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.