Skip to main content

Webhooks

Two different outbound webhook systems

This page covers the workspace webhook, created through the API and independent of any workflow. A workflow's Webhook (HTTP Request) action, documented in Workflows, is a separate feature with its own signing and retry contract; see Workflow Webhook action vs. workspace webhook below for the differences.

Overview​

A workspace webhook is a signed HTTP endpoint with retries, a delivery log, and a Dead Letter Queue. You create one through the API, and it receives test deliveries only for now: the test event and geofence test endpoints send to it, and real geofence events are not delivered to it. To receive real geofence events at your own endpoint, add a Webhook action to a workflow; Settings > Webhooks in the dashboard links there. See Webhook (HTTP Request) for the action and its signing.

What are Webhooks?​

Webhooks are HTTP callbacks that SpatialFlow sends to your server when specific events occur. Rather than continuously polling our API for updates, webhooks provide a push-based mechanism where SpatialFlow proactively notifies your application of important events.

Common Use Cases​

  • Real-time alerts: Send notifications to your own systems when vehicles arrive at customer locations (use a workflow Webhook action for real events)
  • Integration triggers: Update CRM records, ticketing systems, or databases when geofence events occur
  • Custom workflows: Trigger complex business logic in your own applications based on location events
  • Audit logging: Maintain your own record of all geofencing activity for compliance

Workflow Webhook action vs. workspace webhook​

SpatialFlow has two ways to send an outbound HTTP request, and they don't share a signing or retry contract:

Workspace webhook (this page)Workflow Webhook action
ConfiguredThe API (POST /api/v1/webhooks/), independent of any workflowAn action node (actionType: "webhook") inside a workflow, connected to a trigger
Fires onTest deliveries only for now, sent from the test endpoints. Real geofence events are not delivered to itWhatever trigger you connect it to
SigningAlways signs. Header X-SF-Signature, HMAC-SHA256 of the raw body, no timestampOpt-in. Send through a webhook integration with a webhook_secret to get X-SpatialFlow-Signature, HMAC-SHA256 of <timestamp>.<body>, plus X-SpatialFlow-Timestamp. No secret means no signature header at all
RetriesUp to 7 retries (8 attempts total) on a fixed schedule, then the Dead Letter QueueUp to 3 attempts by default, retrying connection failures, timeouts and 5xx or 429 responses only. retry_count in the action config sets the retries: 0 sends once, 1 to 10 gives 2 to 11 attempts. No Dead Letter Queue

The rest of this page documents the workspace webhook. For the workflow action's other settings (headers, auth, timeout), see Webhook (HTTP Request) in the Workflows reference.

Webhook Payload​

Every webhook delivery includes a JSON payload with standardized event information.

Request Structure​

SpatialFlow makes HTTP POST requests to your webhook endpoint with the following characteristics:

  • Method: POST (configurable to PUT, PATCH, or GET)
  • Content-Type: application/json (default, configurable)
  • Timeout: 30 seconds (default, configurable up to 300s)
  • User-Agent: SpatialFlow-Webhooks/1.0

Headers​

Each webhook request includes security and metadata headers:

HeaderDescriptionExample
X-SF-SignatureHMAC-SHA256 signature for verificationsha256=a1b2c3d4...
X-SF-Event-IDUnique identifier for the event (not signed)gf_abc123_enter_1729520000
X-Idempotency-KeyDelivery UUID, the same value as the body's id (not signed)550e8400-e29b-41d4-a716-446655440000
X-SF-AttemptCurrent attempt number (1-8)1
Content-TypeRequest content typeapplication/json
User-AgentSpatialFlow identifierSpatialFlow-Webhooks/1.0

Custom Headers​

You can add custom headers to webhook requests when creating or updating a webhook:

{
"name": "My Webhook",
"url": "https://example.com/webhook",
"headers": {
"X-Custom-API-Key": "your-secret-key",
"X-Tenant-ID": "tenant-123"
}
}

Payload Format​

The JSON body includes comprehensive event information:

{
"id": "550e8400-e29b-41d4-a716-446655440000",
"event": "geofence.enter",
"timestamp": "2025-10-21T14:12:03Z",
"data": {
"event_id": "gf_abc123_enter_1729520000",
"user_id": "user-789",
"geofence_id": "770fa600-g4ad-63f6-c938-668877662222",
"geofence_name": "Warehouse A",
"event_type": "enter",
"point": {
"type": "Point",
"coordinates": [-73.756, 42.651]
},
"metadata": {
"geofence_metadata": {
"zone_type": "delivery",
"priority": "high"
},
"event_metadata": {
"device_type": "mobile",
"app_version": "2.1.0"
}
}
}
}

Event Types​

  • geofence.enter - Device entered a geofence
  • geofence.exit - Device exited a geofence
  • webhook.test - Test event from webhook configuration

For now only the test endpoints send these to a workspace webhook. A workflow Webhook action sends the body you write, not these event types.

Webhook Security​

Security is critical for webhook delivery. SpatialFlow implements multiple security measures to ensure authenticity and prevent tampering.

Request Signing​

Every webhook request includes an HMAC-SHA256 signature in the X-SF-Signature header. This signature proves the request came from SpatialFlow and hasn't been modified in transit.

How Signatures Work​

  1. SpatialFlow generates a unique secret when you create a webhook
  2. For each delivery, we compute an HMAC-SHA256 hash of the raw JSON payload using your secret
  3. The signature is sent in the X-SF-Signature header as sha256=<hex_digest>
  4. Your endpoint should verify this signature before processing the webhook

Getting the signing secret​

SpatialFlow shows a webhook's secret once, when you create the webhook: the 201 response to POST /api/v1/webhooks/ includes a secret field. Listing, getting and updating a webhook never return it.

Keep the secret where your endpoint can read it, such as an environment variable or a secret manager. If you don't have a webhook's secret, because you lost it or the webhook was created before SpatialFlow showed secrets, rotate it to get a new one.

Rotating the secret​

Owners and managers can replace a webhook's secret at any time:

POST /api/v1/webhooks/{webhook_id}/rotate-secret
Authorization: Bearer YOUR_JWT_TOKEN

The response is the webhook with its new secret, which is shown only this once. Rotation takes effect immediately, with no overlap period: every delivery signed from then on uses the new secret, including retries of events that failed earlier, and the old secret stops matching. Update your endpoint right after you rotate. Deliveries it rejects in the meantime, including one that was already on its way when you rotated, go through the usual retry schedule and are signed with the new secret when they're retried.

Verification Examples​

These verifiers check the signature only. The workspace webhook signature has no timestamp, so they cannot reject an old request. To defend against replays, deduplicate on the signed id as described under Idempotency.

Python

import hmac
import hashlib

def verify_webhook_signature(request_body: bytes, signature_header: str, secret: str) -> bool:
"""
Verify webhook signature.

Args:
request_body: Raw request body bytes (don't parse JSON first!)
signature_header: Value of X-SF-Signature header
secret: Your webhook secret from SpatialFlow

Returns:
True if signature is valid, False otherwise
"""
if not signature_header or not signature_header.startswith("sha256="):
return False

received_signature = signature_header[7:] # Remove "sha256=" prefix

# Compute expected signature
computed_signature = hmac.new(
secret.encode('utf-8'),
request_body,
hashlib.sha256
).hexdigest()

# Constant-time comparison of bytes; comparing str raises TypeError on a
# non-ASCII header instead of returning False.
return hmac.compare_digest(computed_signature.encode(), received_signature.encode())

# Usage with Flask
from flask import Flask, request

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 # Flask answers 413 for larger bodies

@app.route('/webhook', methods=['POST'])
def handle_webhook():
signature = request.headers.get('X-SF-Signature')
secret = "your-webhook-secret" # From SpatialFlow webhook config

if not verify_webhook_signature(request.data, signature, secret):
return {"error": "Invalid signature"}, 401

# Process webhook payload
payload = request.json
print(f"Received event: {payload['event']}")

return {"success": True}, 200

Node.js

const crypto = require('crypto');
const express = require('express');

function verifyWebhookSignature(requestBody, signatureHeader, secret) {
/**
* Verify webhook signature.
*
* @param {string|Buffer} requestBody - Raw request body
* @param {string} signatureHeader - Value of X-SF-Signature header
* @param {string} secret - Your webhook secret from SpatialFlow
* @returns {boolean} True if signature is valid
*/
if (!signatureHeader || !signatureHeader.startsWith('sha256=')) {
return false;
}

const receivedSignature = signatureHeader.substring(7); // Remove "sha256=" prefix

// Compute expected signature
const hmac = crypto.createHmac('sha256', secret);
hmac.update(requestBody);
const computedSignature = hmac.digest('hex');

// Constant-time comparison; timingSafeEqual throws on buffers of different lengths
const received = Buffer.from(receivedSignature);
const computed = Buffer.from(computedSignature);
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}

// Usage with Express
const app = express();

app.post('/webhook', express.raw({type: 'application/json', limit: '1mb'}), (req, res) => {
const signature = req.headers['x-sf-signature'];
const secret = process.env.WEBHOOK_SECRET; // From SpatialFlow

if (!verifyWebhookSignature(req.body, signature, secret)) {
return res.status(401).json({error: 'Invalid signature'});
}

// Process webhook payload
const payload = JSON.parse(req.body);
console.log(`Received event: ${payload.event}`);

res.json({success: true});
});

app.listen(3000, () => console.log('Webhook server running on port 3000'));
Important

Always verify the signature before processing webhook data. Never trust webhook requests without verification, as malicious actors could attempt to spoof requests to your endpoint.

Header Naming

SpatialFlow sends outbound webhook deliveries with the X-SF-Signature header. If you are using SpatialFlow's inbound webhook receiver (POST /api/v1/webhooks/receive/{webhook_id}, for third-party webhooks sent to SpatialFlow), the signature is read from the X-Webhook-Signature header (with an X-Webhook-Timestamp companion). These are separate systems; most integrations only need X-SF-Signature for verifying deliveries from SpatialFlow.

Idempotency​

The id field of the signed body is a unique UUID for each delivery, and a retry of the same delivery carries the same id. Verify the signature first, then deduplicate on that id so processing happens once. SpatialFlow records any 2xx response as a successful delivery and does not send it again, so acknowledge only work that is committed.

The X-Idempotency-Key and X-SF-Event-ID headers are not covered by the signature. Anyone who holds a valid delivery can resend the same body and signature with a different header, so deduplicating on a header does not stop a replay.

The default payload carries a signed id, the delivery UUID. A custom_payload_template replaces the whole body with the rendered template, so the template must include a stable id for replay protection: "id": "{{ event.id }}". In a template, event is the default payload, and data, timestamp, and event_type are also available. If a verified body has no id, reject it with a 400 instead of processing it without replay protection.

Record the id in the same transaction as the work. Insert the id with a targeted conflict clause. If no row comes back, the delivery was already processed, so return 200 without doing the work. Otherwise do the work in the same transaction. Any exception rolls back the marker and the work together and returns a non-2xx response, so the retry processes the event. Do not catch a broad integrity error around the work: a constraint failure inside process_event must not look like a duplicate.

def handle_webhook(request):
# Verify signature first
if not verify_webhook_signature(request.body, request.headers.get('X-SF-Signature'), secret):
return {"error": "Invalid signature"}, 401

payload = request.json()
delivery_id = payload.get('id')
if not delivery_id:
# A custom_payload_template without an id leaves nothing to deduplicate on
return {"error": "Missing delivery id"}, 400

with db.transaction():
# processed_webhooks.id is the primary key
row = db.execute(
"INSERT INTO processed_webhooks (id) VALUES (%s) "
"ON CONFLICT (id) DO NOTHING RETURNING id",
[delivery_id],
).fetchone()
if row is None:
return {"status": "already_processed"}, 200

process_event(payload) # its writes join the same transaction
# An exception rolls back the marker and the work, and surfaces as a 5xx

return {"success": True}, 200

A queue counts as accepting the work only if it has persisted the message before you respond. Redis does that only with appendfsync always. Do not claim the id in a cache before the work runs: a crash, or a retry that arrives while the first request is still running, would get a 200 for work that never finished.

The receiver under Example Implementation uses the transaction pattern.

Delivery Guarantees​

SpatialFlow implements robust delivery guarantees to ensure your webhooks are delivered reliably.

Retry Logic​

If a webhook delivery fails, SpatialFlow automatically retries with exponential backoff:

AttemptDelay After FailureTotal Time Elapsed
115 minutes0s
230 minutes15m
31 hour45m
44 hours1h 45m
54 hours5h 45m
64 hours9h 45m
74 hours13h 45m
8(final attempt)17h 45m

The delivery is tried once, then retried up to 7 times (8 attempts total) on the fixed schedule [15m, 30m, 1h, 4h, 4h, 4h, 4h] (±10% jitter per retry). This schedule is a platform-wide setting, not something a webhook's own configuration changes.

Total retry window: Approximately 17 hours 45 minutes from first failure to final attempt.

What max_retries on the webhook actually controls

A webhook has its own max_retries field (default 3, 0 to 10), but it doesn't change the automatic retry schedule above, which always runs its full 7 retries regardless of this value. max_retries instead gates the manual retry endpoint: once a delivery's attempt count reaches it, a manual retry needs {"force": true} in the body. In practice this means a delivery still in its automatic retry cycle can already require force for a manual retry, well before it reaches the DLQ.

Success Criteria​

A webhook delivery is considered successful if:

  • HTTP response status code is 2xx (200-299)
  • Response received within timeout period (default 30 seconds)

Failure Conditions​

A delivery is retried if:

  • HTTP status code is not 2xx (200-299)
  • Connection timeout (exceeds configured timeout)
  • Connection error (DNS failure, network error, refused connection, etc.)

Dead Letter Queue (DLQ)​

When a webhook fails all 8 delivery attempts (the initial try plus 7 retries), it's moved to the Dead Letter Queue (DLQ). The DLQ provides:

  • Visibility into permanent failures
  • Manual retry capability via API
  • Error debugging information with full error details

Viewing Failed Deliveries​

GET /api/v1/webhooks/dlq?limit=50&offset=0&requeued=false

Response includes failed deliveries with error details:

{
"results": [
{
"dlq_id": "550e8400-e29b-41d4-a716-446655440000",
"delivery_id": "660e9500-f39c-52e5-b827-557766551111",
"webhook": {
"id": "770fa600-g4ad-63f6-c938-668877662222",
"name": "Production Alerts",
"url": "https://example.com/webhook"
},
"event_type": "geofence.enter",
"event_id": "gf_abc123_enter_1729520000",
"error_message": "HTTP 500: Internal Server Error",
"retry_count": 7,
"last_attempt_at": "2025-10-21T14:30:00Z",
"queued_at": "2025-10-21T14:30:00Z",
"requeued": false
}
],
"pagination": {
"total": 12,
"limit": 50,
"offset": 0,
"has_more": false
}
}

Retrying from DLQ​

After fixing the underlying issue (e.g., updating webhook URL or fixing your endpoint), retry failed deliveries. Because a DLQ'd delivery has already used all of its attempts, you must pass {"force": true} in the body to re-run it:

POST /api/v1/webhooks/{webhook_id}/deliveries/{delivery_id}/retry
Content-Type: application/json

{"force": true}

This creates a new delivery attempt with a fresh retry counter. (Alternatively, replay by DLQ entry with POST /api/v1/webhooks/dlq/{dlq_id}/retry, which resets the counter and needs no body.)

Registering a Webhook​

Owners and managers create and manage webhooks with a JWT bearer token; the webhook management endpoints don't accept an API key, even though Webhooks is one of the permission resources an API key can be scoped to. Creating a webhook is rate limited to 20 per hour.

POST /api/v1/webhooks/
Authorization: Bearer YOUR_JWT_TOKEN
Content-Type: application/json

{
"name": "Fleet Entry Alerts",
"description": "Notifies dispatch when vehicles enter warehouse zones",
"url": "https://example.com/webhooks/geofence-events",
"events": ["enter", "exit"],
"auth_type": "bearer",
"auth_config": {"token": "your-bearer-token"},
"max_retries": 3,
"timeout_seconds": 30,
"rate_limit_per_minute": 60
}

events takes the bare event names (enter, exit), not the namespaced form the delivered payload uses (geofence.enter, geofence.exit). auth_type is none, bearer, basic, or api_key; SpatialFlow adds the corresponding header to every outbound delivery using auth_config.

The 201 response is the new webhook plus a secret field holding its signing secret. No other read returns the secret, so store it now. See Getting the signing secret.

Creating Webhook Endpoints​

Requirements​

Your webhook endpoint must:

  • Respond quickly: Return a response within the configured timeout (default 30s)
  • Return 2xx status: Any status code in the 200-299 range indicates success
  • Handle retries: Process the same event multiple times safely (deduplicate on the signed id in the body)
  • Verify signatures: Always verify the X-SF-Signature header before processing

Best Practices​

The snippets in this section and under Idempotency are fragments, with db, verify_signature, and process_event standing in for your own code. The receiver under Example Implementation runs as written.

1. Respond Immediately​

Keep the handler short, but acknowledge only work that is committed. For slow work, write the delivery to an inbox table in a transaction and respond once it commits. A separate worker processes the rows:

from flask import Flask, request

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 # Flask answers 413 for larger bodies

@app.route('/webhook', methods=['POST'])
def webhook():
# Verify signature
if not verify_signature(request.data, request.headers.get('X-SF-Signature')):
return {"error": "Invalid signature"}, 401

payload = request.get_json()
delivery_id = payload.get('id')
if not delivery_id:
return {"error": "Missing delivery id"}, 400

with db.transaction():
# webhook_inbox.id is the primary key; a duplicate delivery inserts nothing
db.execute(
"INSERT INTO webhook_inbox (id, body) VALUES (%s, %s) ON CONFLICT (id) DO NOTHING",
[delivery_id, request.data],
)

# Respond after the commit
return {"success": True}, 200

# A separate worker reads unprocessed rows from webhook_inbox and calls process_event

2. Implement Idempotency​

Deduplicate on the signed id and acknowledge only work that is committed. Record the id in the same database transaction as the work, as in the receiver below. See Idempotency for the transaction example.

3. Handle Errors Gracefully​

Log errors and return appropriate status codes:

@app.route('/webhook', methods=['POST'])
def webhook():
try:
# Verify signature
if not verify_signature(request.data, request.headers.get('X-SF-Signature')):
return {"error": "Invalid signature"}, 401

# Process webhook
result = process_event(request.json)

return {"success": True, "result": result}, 200

except ValidationError as e:
# Validation errors should not be retried
logger.error(f"Validation error: {e}")
return {"error": "Invalid payload"}, 400

except Exception as e:
# Unexpected errors trigger retries
logger.error(f"Webhook processing error: {e}")
return {"error": "Internal server error"}, 500

4. Monitor and Alert​

Set up monitoring for webhook delivery failures:

import logging
from prometheus_client import Counter

webhook_received = Counter('webhook_received_total', 'Total webhooks received')
webhook_processed = Counter('webhook_processed_total', 'Total webhooks processed successfully')
webhook_failed = Counter('webhook_failed_total', 'Total webhook processing failures')

@app.route('/webhook', methods=['POST'])
def webhook():
webhook_received.inc()

try:
process_event(request.json)
webhook_processed.inc()
return {"success": True}, 200
except Exception as e:
webhook_failed.inc()
logger.error(f"Webhook failed: {e}")
return {"error": str(e)}, 500

Example Implementation​

Complete webhook receiver with all best practices. It needs only Flask and the standard library, and it uses SQLite (3.35 or later) so it runs as is. Swap in your own database and process_event.

import hashlib
import hmac
import json
import logging
import sqlite3
import threading
from flask import Flask, request

app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024 # Flask answers 413 for larger bodies
logger = logging.getLogger(__name__)

WEBHOOK_SECRET = "your-webhook-secret"

# One connection with explicit transactions, guarded by a lock
db = sqlite3.connect("webhooks.db", isolation_level=None, check_same_thread=False)
db_lock = threading.Lock()
db.executescript("""
CREATE TABLE IF NOT EXISTS processed_webhooks (id TEXT PRIMARY KEY);
CREATE TABLE IF NOT EXISTS events (delivery_id TEXT, event TEXT, data TEXT);
""")

def process_event(payload: dict) -> None:
"""Your business logic. Its writes join the caller's transaction."""
db.execute(
"INSERT INTO events (delivery_id, event, data) VALUES (?, ?, ?)",
(payload["id"], payload.get("event"), json.dumps(payload.get("data"))),
)

def verify_signature(body: bytes, signature: str) -> bool:
"""Verify HMAC signature"""
if not signature or not signature.startswith("sha256="):
return False

received = signature[7:]
computed = hmac.new(
WEBHOOK_SECRET.encode('utf-8'),
body,
hashlib.sha256
).hexdigest()

# Compare bytes: str comparison raises TypeError on a non-ASCII header.
return hmac.compare_digest(computed.encode(), received.encode())

@app.route('/webhook', methods=['POST'])
def webhook():
"""Handle SpatialFlow webhook"""

# 1. Verify signature
signature = request.headers.get('X-SF-Signature')
if not verify_signature(request.data, signature):
logger.warning("Invalid webhook signature")
return {"error": "Invalid signature"}, 401

# 2. Read the signed delivery id
payload = request.get_json(silent=True) or {}
delivery_id = payload.get('id')
if not delivery_id:
return {"error": "Missing delivery id"}, 400

with db_lock:
db.execute("BEGIN")
try:
# 3. Record the id and do the work in one transaction
row = db.execute(
"INSERT INTO processed_webhooks (id) VALUES (?) "
"ON CONFLICT (id) DO NOTHING RETURNING id",
(delivery_id,),
).fetchone()
if row is None:
db.execute("ROLLBACK")
logger.info(f"Duplicate webhook: {delivery_id}")
return {"status": "already_processed"}, 200

logger.info(f"Received webhook: {payload.get('event')}")
process_event(payload)
db.execute("COMMIT")
except Exception as e:
# Roll back the marker and the work, so SpatialFlow's retry processes the event
db.execute("ROLLBACK")
logger.error(f"Webhook error: {e}")
return {"error": "Internal server error"}, 500

# 4. Respond only after the commit
return {"success": True}, 200

if __name__ == '__main__':
app.run(port=3000)

Testing Webhooks​

Using Test Events​

Trigger a test webhook delivery through the API:

POST /api/v1/webhooks/{webhook_id}/test

This sends a test event with the webhook.test event type.

Local Development​

For local testing, use tools like ngrok to expose your local server:

# Start your webhook server locally
python webhook_server.py

# In another terminal, expose it
ngrok http 3000

# Use the ngrok URL in SpatialFlow
# https://abc123.ngrok.io/webhook

Debugging Tips​

  1. Check signature verification: Ensure you're using the correct secret
  2. Verify raw body: Use the raw request body for signature verification, not parsed JSON
  3. Test with curl: Simulate webhook requests manually
  4. Check logs: Review the webhook's delivery log through the API
  5. Monitor DLQ: Check the Dead Letter Queue for failed deliveries

Monitoring & Debugging​

Webhook Delivery Logs​

View delivery history and status for each webhook:

GET /api/v1/webhooks/{webhook_id}/deliveries?limit=50

Response includes delivery attempts with timing and status:

{
"results": [
{
"id": "delivery-123",
"status": "success",
"event_type": "geofence.enter",
"response_status_code": 200,
"response_time_ms": 145.3,
"attempt_count": 1,
"created_at": "2025-10-21T14:12:03Z",
"delivered_at": "2025-10-21T14:12:03Z",
"next_retry_at": null
}
]
}

Common Issues​

IssueCauseSolution
401 UnauthorizedSignature verification failedCheck you're using the webhook's current secret (rotating replaces it immediately) and the raw request body
TimeoutResponse took longer than configured timeoutImplement async processing, return 200 immediately
Connection refusedWebhook URL unreachableVerify URL is correct and server is running
SSL errorsCertificate issuesEnsure HTTPS endpoint has valid SSL certificate
500 errorsApplication error in your endpointCheck application logs, fix bugs in webhook handler

DLQ Statistics​

Monitor Dead Letter Queue depth:

GET /api/v1/webhooks/dlq/stats
{
"total_entries": 12,
"not_requeued": 8,
"requeued": 4,
"top_failed_webhooks": [
{
"webhook_id": "770fa600-g4ad-63f6-c938-668877662222",
"webhook_name": "Production Alerts",
"count": 5
}
]
}

Set up alerts when DLQ depth exceeds thresholds.

Rate Limits​

Each webhook stores a configurable per-minute rate-limit value:

  • Default: 60 requests per minute per webhook
  • Configurable: Set a custom rate_limit_per_minute when creating webhooks
note

This value is stored and surfaced on the webhook, but per-webhook delivery is not currently throttled against it at send time; deliveries are dispatched as events occur. Treat rate_limit_per_minute as configuration for a future enforcement pass, not a live guarantee. Design your receiver to absorb bursts.

Configure the value when creating a webhook:

{
"name": "My Webhook",
"url": "https://example.com/webhook",
"rate_limit_per_minute": 120
}

Next Steps​