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
urlmust 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.
secretis generated server-side (32 bytes, base64-encoded) and returned once in the creation response. It is stored encrypted at rest usingdjango-fernet-encrypted-fields(the sameSALT_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
| 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 Header
X-SpeedPy-Signature: t=1719057600,v1=5d41402abc4b2a76b9719d911017c592...
| Component | Description |
|---|---|
t | Unix timestamp of the signing moment |
v1 | HMAC-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:
| 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 viaTeamMembership. - Inactive teams (
is_active=False) do not receive deliveries. - Creating/managing subscriptions requires the
owneroradminrole 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:
- Register the event — add a constant to
WebhookEventinmainapp/webhooks/events.pyand include it in theALLfrozenset. - Dispatch from business logic — call
dispatch_event(team, event_type, data)after the relevant database write. Thedatadict should contain only stable IDs and UTC timestamps — envelope fields (event_id,event_type,timestamp,api_version) are added automatically. - Add a test — assert that a
WebhookDeliveryrow is created with the correctevent_typeand payload shape inmainapp/tests/test_webhooks.py. - 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 |