Skip to main 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.

FieldTypeDescription
idUUIDPrimary key
teamFK → TeamOwning team (tenant scope)
urlURLHTTPS endpoint that receives POST requests
eventslist[str]Event types this subscription listens to (e.g. ["team.member.added"]), or ["*"] for all
secretstringHMAC signing secret (shown once at creation, stored encrypted at rest)
is_activeboolEnables/disables delivery without deleting the subscription
created_atdatetimeWhen the subscription was created
updated_atdatetimeLast 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. Rotation is handled by creating a new subscription and deactivating the old one (dedicated rotation support planned for F3).

Event Taxonomy

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

v1 Events

EventTrigger
team.member.addedA user joins a team (invitation accepted or direct add)
team.invitation.createdA new team invitation is sent
user.profile.updatedA 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"
}
}
FieldDescription
idUnique event ID (evt_ prefix + UUID). Consumers use this for idempotent processing.
typeEvent name from the taxonomy above.
api_versionPayload schema version. Always "v1" for now.
created_atISO 8601 timestamp of when the event occurred.
team_idThe team this event belongs to.
dataEvent-specific payload. Shape varies by event type.

HMAC Signing

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

Signature Header

X-SpeedPy-Signature: t=1719057600,v1=5d41402abc4b2a76b9719d911017c592...
ComponentDescription
tUnix timestamp of the signing moment
v1HMAC-SHA256 hex digest

Verification Algorithm

import hashlib, hmac

timestamp = header_parts["t"] # str, e.g. "1719057600"
expected = header_parts["v1"] # 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, expected):
raise ValueError("Invalid webhook signature")

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

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:

AttemptDelay
1immediate
230 seconds
32 minutes
415 minutes
51 hour
64 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:

FieldDescription
subscriptionFK → WebhookSubscription
event_idThe event id
event_typeEvent name
payloadFull serialized JSON payload (persisted for DLQ replay and audit)
attemptAttempt number (1–6)
next_retry_atScheduled time for the next attempt (null after final attempt or success)
status_codeHTTP response code (or null for network error)
response_bodyFirst 1 KB of response (for debugging)
duration_msRound-trip time
created_atTimestamp of this attempt
statuspending / 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}/ # Delete subscription
GET /api/v1/teams/{team_id}/webhooks/{id}/logs/ # Delivery logs
POST /api/v1/teams/{team_id}/webhooks/{id}/test/ # Send a test event

Required scope: webhooks:manage (new scope — must be registered in the OAuth2 scope registry and added to docs/integrations.md as part of C2 implementation). Requires owner or admin role.

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

SettingDefaultDescription
SPEEDPY_WEBHOOKS_ENABLEDFalseFeature flag — when off, no deliveries are dispatched
SPEEDPY_WEBHOOKS_MAX_SUBSCRIPTIONS_PER_TEAM10Maximum subscriptions per team
SPEEDPY_WEBHOOKS_DELIVERY_TIMEOUT10HTTP timeout in seconds per attempt
SPEEDPY_WEBHOOKS_MAX_RETRIES6Maximum delivery attempts before dead-letter