Skip to content

Webhooks

Overview

SpeedPy supports outbound webhooks so external systems can react to events in real time. A team registers an endpoint URL, chooses which events to subscribe to, and SpeedPy delivers signed JSON payloads via HTTP POST as events occur.

Webhooks are team-scoped — each subscription belongs to a Team and only receives events for that team. User-level events (e.g. user.profile.updated) are delivered to every team the user belongs to.

Subscription Model

A webhook subscription ties an endpoint URL to a team and a set of event types.

Field Type Description
id UUID Primary key
team FK → Team Owning team (tenant scope)
url URL HTTPS endpoint that receives POST requests
events list[str] Event types this subscription listens to (e.g. ["team.member.added"]), or ["*"] for all
secret string HMAC signing secret (shown once at creation, stored encrypted at rest)
is_active bool Enables/disables delivery without deleting the subscription
created_at datetime When the subscription was created
updated_at datetime Last modification timestamp

Constraints

  • url must use HTTPS. Private, loopback, and link-local IPs are rejected at creation time and re-validated on every delivery attempt (DNS resolution is checked before each HTTP request to prevent DNS-rebinding and redirect-based SSRF). HTTP redirects are not followed.
  • A team can have multiple subscriptions pointing to the same URL with different event filters.
  • secret is generated server-side (32 bytes, base64-encoded) and returned once in the creation response. It is stored encrypted at rest using django-fernet-encrypted-fields (the same SALT_KEY-derived encryption used elsewhere in SpeedPy) because the server must retrieve the raw secret to compute HMAC signatures on every delivery. The secret is never exposed via the API after creation. See Secret Rotation below for how to rotate the signing secret without deleting the endpoint.

Event Taxonomy

Events follow a resource.sub_resource.action naming convention. All event names are lowercase, dot-separated.

v1 Events

Event Trigger
team.member.added A user joins a team (invitation accepted or direct add)
team.invitation.created A new team invitation is sent
user.profile.updated A team member updates their profile (name, avatar, etc.)

Additional events will be added in follow-up tickets (C2–C6). The event list is defined in mainapp/webhooks/events.py — see Event Constants below.

Payload Envelope

Every webhook delivery uses the same envelope format:

{
  "id": "evt_01J5K3...",
  "type": "team.member.added",
  "api_version": "v1",
  "created_at": "2026-06-22T12:00:00Z",
  "team_id": "d4f8a...",
  "data": {
    "user_id": "a1b2c...",
    "email": "alice@example.com",
    "role": "member"
  }
}
Field Description
id Unique event ID (evt_ prefix + UUID). Consumers use this for idempotent processing.
type Event name from the taxonomy above.
api_version Payload schema version. Always "v1" for now.
created_at ISO 8601 timestamp of when the event occurred.
team_id The team this event belongs to.
data Event-specific payload. Shape varies by event type.

HMAC Signing

Every delivery is signed so the receiver can verify authenticity and integrity.

Signature Headers

Every delivery includes two signature-related headers:

Header Description
X-SpeedPy-Timestamp Unix timestamp of the signing moment (e.g. 1719057600)
X-SpeedPy-Signature HMAC-SHA256 hex digest

Additional headers sent with every delivery:

Header Description
X-SpeedPy-Event Event type (e.g. team.member.added)
X-SpeedPy-Delivery Unique event ID for deduplication

Verification Algorithm

import hashlib, hmac

timestamp = request.headers["X-SpeedPy-Timestamp"]   # str, e.g. "1719057600"
signature = request.headers["X-SpeedPy-Signature"]   # hex digest str
raw_body: bytes = request.body                        # raw HTTP request body

signed_payload = timestamp.encode() + b"." + raw_body
computed = hmac.new(
    secret.encode(),
    signed_payload,
    hashlib.sha256,
).hexdigest()

if not hmac.compare_digest(computed, signature):
    raise ValueError("Invalid webhook signature")

Timestamp tolerance: consumers should reject deliveries where X-SpeedPy-Timestamp is more than 5 minutes from the current time to prevent replay attacks.

Secret Rotation

Signing secrets can be rotated without deleting the endpoint or interrupting deliveries. When you rotate a secret, the previous secret remains accepted by verifiers during a configurable overlap window (default: 24 hours).

How to Rotate

API:

POST /api/v1/teams/{team_id}/webhooks/{id}/rotate-secret/

The response includes the new signing_secret (shown once), plus rotation metadata:

{
  "id": "f1e2d3c4-...",
  "signing_secret": "whsec_new_secret...",
  "secret_rotated_at": "2026-06-26T12:00:00Z",
  "previous_secret_expires_at": "2026-06-27T12:00:00Z",
  "rotation_overlap_seconds": 86400
}

UI: Use the "Rotate secret" button on the webhook detail page. The new secret is displayed once after rotation.

Overlap Window

During the overlap window, consumers should accept signatures computed with either the old or the new secret. This allows time for consumers to update their stored secret without missing events.

Recommended verifier flow:

  1. Rotate the secret via API or UI. Copy the new secret.
  2. Update your verification code to accept both the old and new secrets.
  3. After the overlap window expires, remove the old secret from your verifier.

Important:

  • Only one previous secret is kept. A second rotation during an active overlap discards the older previous secret.
  • New deliveries are always signed with the active (new) secret immediately after rotation.
  • Retried deliveries are signed at execution time using the active secret, not the secret that was active when the event was first created.
  • The overlap window is configurable via SPEEDPY_WEBHOOK_SECRET_ROTATION_OVERLAP_SECONDS (default: 86400 = 24 hours).

Delivery Guarantees

Webhooks use at-least-once delivery. Consumers must be idempotent — use the id field to deduplicate.

Retry & Backoff

Failed deliveries (non-2xx response or network error) are retried with exponential backoff:

Attempt Delay
1 immediate
2 30 seconds
3 2 minutes
4 15 minutes
5 1 hour
6 4 hours

After 6 failed attempts, the event is moved to the dead-letter queue (DLQ).

Timeout: each delivery attempt times out after 10 seconds. The endpoint must respond within this window.

Dead-Letter Queue

Events that exhaust all retries are stored in a WebhookDeliveryLog with status dead_letter. These can be:

  • Inspected via the API or Django admin
  • Manually retried (re-enqueues with fresh retry count)

If a subscription accumulates 50+ consecutive failures, it is automatically disabled (is_active = False) and a webhook.subscription.disabled system event is logged. The team must re-enable it manually after fixing the endpoint.

Delivery Log

Every delivery attempt is logged:

Field Description
subscription FK → WebhookSubscription
event_id The event id
event_type Event name
payload Full serialized JSON payload (persisted for DLQ replay and audit)
attempt Attempt number (1–6)
next_retry_at Scheduled time for the next attempt (null after final attempt or success)
status_code HTTP response code (or null for network error)
response_body First 1 KB of response (for debugging)
duration_ms Round-trip time
created_at Timestamp of this attempt
status pending / success / failed / dead_letter

Team Scoping

Webhook subscriptions follow TeamModel tenancy:

  • Every subscription belongs to exactly one team.
  • Events are dispatched only to subscriptions on the team where the event occurred.
  • User-level events (e.g. user.profile.updated) are dispatched to all teams the user belongs to via TeamMembership.
  • Inactive teams (is_active=False) do not receive deliveries.
  • Creating/managing subscriptions requires the owner or admin role on the team.

v1 Management Surface

API

Webhook subscriptions are managed via the REST API:

POST   /api/v1/teams/{team_id}/webhooks/                              # Create subscription
GET    /api/v1/teams/{team_id}/webhooks/                               # List subscriptions
GET    /api/v1/teams/{team_id}/webhooks/{id}/                          # Get subscription details
PATCH  /api/v1/teams/{team_id}/webhooks/{id}/                          # Update (events, url, is_active)
DELETE /api/v1/teams/{team_id}/webhooks/{id}/                          # Soft-delete (deactivate)
POST   /api/v1/teams/{team_id}/webhooks/{id}/rotate-secret/            # Rotate signing secret
POST   /api/v1/teams/{team_id}/webhooks/{id}/test/                     # Send a test event
GET    /api/v1/teams/{team_id}/webhooks/{id}/deliveries/               # Delivery logs
GET    /api/v1/teams/{team_id}/webhooks/{id}/deliveries/{delivery_id}/ # Delivery detail
POST   /api/v1/teams/{team_id}/webhooks/{id}/deliveries/{delivery_id}/retry/ # Retry failed delivery
GET    /api/v1/webhooks/                                               # List across all user's teams

Required scopes: read:webhooks (for GET endpoints) and write:webhooks (for POST, PATCH, DELETE). Requires owner, admin, or member role on the team.

Django Admin

Operators can view and manage all subscriptions and delivery logs in the Django admin. This is the only management UI in v1 — a self-service settings UI for team admins is planned in C5.

Event Constants

Event types are defined as constants in mainapp/webhooks/events.py so they can be referenced consistently across the codebase (signals, serializers, delivery logic):

from mainapp.webhooks.events import WebhookEvent

WebhookEvent.TEAM_MEMBER_ADDED        # "team.member.added"
WebhookEvent.TEAM_INVITATION_CREATED  # "team.invitation.created"
WebhookEvent.USER_PROFILE_UPDATED     # "user.profile.updated"
WebhookEvent.ALL                      # frozenset of all event names
WebhookEvent.CHOICES                  # Django choices tuple

New events added in follow-up tickets (C2–C6) should be registered in this module.

Adding New Events

Fork owners can add custom webhook events without modifying the delivery infrastructure. The full step-by-step recipe lives in AGENTS.md (the developer/agent guide in the speedpy repo). In summary:

  1. Register the event — add a constant to WebhookEvent in mainapp/webhooks/events.py and include it in the ALL frozenset.
  2. Dispatch from business logic — call dispatch_event(team, event_type, data) after the relevant database write. The data dict should contain only stable IDs and UTC timestamps — envelope fields (event_id, event_type, timestamp, api_version) are added automatically.
  3. Add a test — assert that a WebhookDelivery row is created with the correct event_type and payload shape in mainapp/tests/test_webhooks.py.
  4. Document the event — add a row to the v1 Events table above.

Wildcard subscriptions (["*"]) automatically pick up new events — no endpoint migration is needed when events are added. Breaking payload changes should increment the api_version.

Configuration

Setting Default Description
SPEEDPY_WEBHOOKS_ENABLED False Feature flag — when off, no deliveries are dispatched
SPEEDPY_WEBHOOKS_MAX_SUBSCRIPTIONS_PER_TEAM 10 Maximum subscriptions per team
SPEEDPY_WEBHOOKS_DELIVERY_TIMEOUT 10 HTTP timeout in seconds per attempt
SPEEDPY_WEBHOOKS_MAX_RETRIES 6 Maximum delivery attempts before dead-letter
SPEEDPY_WEBHOOK_SECRET_ROTATION_OVERLAP_SECONDS 86400 Duration (seconds) the previous secret remains valid after rotation