Webhooks
An endpoint's name follows the platform's object name rules: trimmed, not blank, at most 128 characters, no word longer than 64, and no control characters. The name is a label; the endpoint is addressed by its generated token, not its name.
Webhooks let external systems (CI/CD, ITSM, monitoring) trigger Hegemony flows by sending an HTTP POST to a per-endpoint URL.
1. Overview & Motivation
Webhooks add an event-driven trigger path that complements existing trigger modes:
| Trigger Mode | Source | Endpoint |
|---|---|---|
| Manual | User (UI / curl) | POST /runs |
| Scheduled | Timer (polling worker) | GET /internal/schedules/due → POST /internal/schedules/{id}/trigger |
| Webhook | External system (CI/CD, ITSM, monitoring) | POST /hooks/{path_token} |
All three paths converge at shared run orchestration paths (trigger_schedule_run for scheduler, create_run_from_request for manual/webhook), ensuring consistent run metadata, notifications, and audit trails.
Trigger Architecture Overview
Every path meets in create_run_from_request(), which is where run admission lives. A webhook is an approved trigger there, so it still starts a flow whose manual runs are turned off — that switch refuses only POST /runs, the path a person drives.
Use cases
| Use Case | Trigger Type |
|---|---|
| Trigger a flow directly (e.g., from CI/CD or monitoring alert) | flow |
| Pass payload params/targets to run inputs | flow |
2. Architecture
Inbound Webhook Sequence (Current)
Component Placement
3. Domain Model
ER Diagram
Enums (packages/core/enums.py)
class WebhookAuthMode(str, Enum):
"""Authentication mode for inbound webhook verification."""
HMAC_SHA256 = "hmac_sha256"
BEARER = "bearer"
class WebhookTargetType(str, Enum):
"""Concrete target type linked to a webhook endpoint."""
FLOW = "flow"Current semantic source of truth:
- Inbound webhooks target exactly one flow via the nullable
webhook_endpoints.flow_idcolumn (strict N:1, mirroringSchedule.flow_id). WebhookTargetType(single valueflow) survives as response vocabulary: it typesWebhookTriggeredRun.target_typein the trigger response envelope.- Fanout is composed inside the flow graph, not at the webhook layer: a webhook that must start several flows targets one parent flow whose nested child flows (
flow.runsteps) run the children.
WebhookEndpoint Model (apps/api/models.py)
Uses TimestampMixin, AuditMixin, SoftDeleteMixin, OrgScopedMixin — same pattern as other managed resources.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | UUID | PK, default uuid4 | |
name | String(255) | NOT NULL | Human-readable name |
description | Text | nullable | |
enabled | Boolean | NOT NULL, default True | Follows NotificationDestination pattern |
path_token | String(64) | NOT NULL, UNIQUE | Generated via secrets.token_urlsafe(32) |
secret_id | UUID | FK → secrets.id, SET NULL | Reuses existing Secret model |
auth_mode | Enum(WebhookAuthMode) | NOT NULL, default hmac_sha256 | |
replay_window_seconds | Integer | NOT NULL, default 300 | Reject timestamps outside window |
rate_limit_per_minute | Integer | NOT NULL, default 60 | Per-endpoint rate limit |
flow_id | UUID | FK → flow_definitions.id, SET NULL, indexed, nullable | The single linked flow (mirrors Schedule.flow_id) |
flow_version | Integer | nullable | Pinned committed version; NULL = latest committed |
allowed_source_ips_json | JSONB | nullable | Optional IP allowlist ["10.0.0.0/8"] |
payload_mapping_json | JSONB | default {} | Reserved; not read by the trigger path |
last_triggered_at | DateTimeTZ | nullable | Last successful trigger time |
+ TimestampMixin | created_at, updated_at | ||
+ AuditMixin | created_by, updated_by | ||
+ SoftDeleteMixin | deleted_at, deleted_by | ||
+ OrgScopedMixin | org_id (NOT NULL, tenant scope) |
Flow Linkage
Each endpoint links at most one flow through webhook_endpoints.flow_id (ON DELETE SET NULL) — the same shape as Schedule.flow_id:
- Triggering with no linked flow (
flow_id IS NULL) is rejected with422 "No linked flow"; the delivery is journaled with that error. - Flow deletion is guarded: deleting a flow (soft or permanent) that a non-deleted endpoint references returns
409 Conflict, and the conflict payload lists the referencing endpoints with links to/webhooks/{id}.ON DELETE SET NULLis the database-level backstop for out-of-band deletions only. - History: earlier phases used an N:N
webhook_endpoint_targetsassociation table (WebhookEndpointTargetmodel). Nested child flows (flow.runsteps) made webhook-level fanout redundant, so migration20260824_039_webhook_single_flow.pyreplaced the association with the singleflow_idcolumn. The upgrade aborts if any non-deleted endpoint still links more than one flow (the operator must re-point or delete those endpoints first), backfills each remaining endpoint with its earliest-created target, and drops the join table; the downgrade recreates it.
WebhookDelivery Model (apps/api/models.py)
Immutable journal — no update operations, no soft delete.
| Column | Type | Constraints | Notes |
|---|---|---|---|
id | UUID | PK, default uuid4 | |
webhook_endpoint_id | UUID | FK → webhook_endpoints.id, CASCADE, NOT NULL | |
idempotency_key | String(255) | nullable | From the X-Idempotency-Key header only; absent when the caller sends none |
signature_valid | Boolean | NOT NULL | Whether HMAC verification passed |
source_ip | String(45) | nullable | Caller IP (IPv4 or IPv6) |
request_body_hash | String(64) | nullable | SHA-256 hex digest (never raw body) |
response_status | Integer | NOT NULL | HTTP status returned |
error_detail | Text | nullable | Error message if failed |
run_ids | JSONB | NOT NULL, default [] | Array of run UUIDs created by this delivery. New deliveries carry exactly one element; historical fanout rows may hold several |
processing_duration_ms | Integer | nullable | Total processing time |
created_at | DateTimeTZ | NOT NULL |
Constraints:
UniqueConstraint("webhook_endpoint_id", "idempotency_key", name="uq_webhook_deliveries_endpoint_idempotency")— dedupe enforcementIndex("ix_webhook_deliveries_endpoint_id", "webhook_endpoint_id")— fast delivery log lookupsIndex("ix_webhook_deliveries_created_at", "created_at")— retention cleanup
The run_ids column stays an array: historical multi-flow deliveries keep their multi-element rows, while new deliveries always record a single run UUID.
4. API Contract
Management Endpoints (Authenticated)
All management endpoints require authentication and use the standard AUTH_ERROR_RESPONSES pattern.
POST /webhooks — Create Webhook Endpoint
Request Body (WebhookEndpointCreate):
{
"name": "CI/CD Deploy Trigger",
"description": "Triggers production deployment flow on successful build",
"flow_id": "uuid",
"secret_id": "uuid",
"auth_mode": "hmac_sha256",
"replay_window_seconds": 300,
"rate_limit_per_minute": 60,
"flow_version": null,
"allowed_source_ips": ["10.0.0.0/8", "172.16.0.0/12"],
"enabled": true
}flow_id is required — every endpoint links exactly one flow. flow_version optionally pins a committed version (null = latest committed).
Response (201 Created): Full WebhookEndpoint read schema (includes generated path_token, plus the resolved flow_id / flow_name / flow_version).
GET /webhooks — List Webhook Endpoints
Query Parameters: include_deleted: bool = False
Response: list[WebhookEndpointListItem] (each item carries flow_id + flow_name)
GET /webhooks/{webhook_id} — Get Webhook Endpoint
Response: Full WebhookEndpoint read schema.
PATCH /webhooks/{webhook_id} — Update Webhook Endpoint
Request Body (WebhookEndpointUpdate): All fields optional. flow_id retargets the endpoint to a different flow; auth_mode is immutable after creation.
Response: Updated WebhookEndpoint.
DELETE /webhooks/{webhook_id} — Soft Delete
Response: 204 No Content
POST /webhooks/{webhook_id}/restore — Restore Soft-Deleted
Response: Restored WebhookEndpoint.
DELETE /webhooks/{webhook_id}/permanent — Hard Delete (Admin Only)
Response: 204 No Content
POST /webhooks/{webhook_id}/rotate-secret — Rotate HMAC Secret (Admin Only)
Request Body:
{
"new_secret_id": "uuid"
}Response: Updated WebhookEndpoint.
POST /webhooks/{webhook_id}/test — Dry-Run Validation
Validates that the endpoint configuration is viable (linked flow exists, secret resolvable, etc.) without triggering.
Response:
{
"valid": true,
"checks": {
"secret_resolvable": true,
"flow_exists": true
},
"errors": []
}GET /webhooks/{webhook_id}/deliveries — List Delivery Log
Query Parameters: limit: int = 50, offset: int = 0
Response: list[WebhookDeliveryRead]
Public Trigger Endpoint
POST /hooks/{path_token}
Registered under the webhook:receive action (public policy) in apps/api/auth/routes.yaml. HMAC verification handled in the service layer.
Flow version selection:
- Callers always invoke the plain trigger URL.
- Version selection is controlled by webhook configuration (
flow_version):latest committed(flow_version = null) → uses the linked flow's current committed version;400if the flow has no committed versionpinned committed version→ uses the configured committed version and rejects the request with404if the linked flow does not have that version
Request body conventions:
- The body must be a JSON object. By default the whole object becomes the run's
params. - Alternatively, top-level
paramsand/orrelated_targetskeys are honored explicitly:paramsmust be an object,related_targetsmaps target roles to arrays of devices. When either key is present, only those keys are used.
Naming a device in related_targets: each entry is either a Hegemony device UUID, or the pair the inventory sync keys provider-sourced devices on:
{
"related_targets": {
"targets": [
"0b7f1e4c-1f3a-4a1e-9a6b-5c2d8e7f0a11",
{ "provider_id": "netbox:primary", "external_id": "1042" }
]
}
}The second form exists because the system that fires the webhook knows its own object id and cannot know the UUID Hegemony minted for it. provider_id is the inventory provider's id exactly as it appears on the device — the provider_id field of the device in the API — which is {type}:{name} in the default organization and {type}:{name}:{org_id} in every other organization. external_id is the object's id in that provider.
Three limits apply, all deliberate:
- A reference resolves only within the endpoint's own organization. A device UUID may also name a device in the shared organization; a provider reference may not, because a UUID cannot be guessed and
netbox:primaryplus a small integer can be. - At most 100 provider references per request, counted across all roles.
- At most
HEGEMONY_INVENTORY_MAX_RUN_TARGETStargets in total (UUIDs and references together, default 10000) — a run may not have more targets than that anyway, and the request is rejected with422before any lookup runs.
A reference that names no device in that organization is rejected with 400, the same way an unknown device UUID is, and the rejection repeats the reference: related_targets names devices not in this organization's inventory: targets: provider_id='netbox:primary' external_id='1042'. That text is recorded in the delivery journal and in the webhook.rejected audit entry, so the usual cause — the source fired before the next sync imported the object — is visible where an operator looks first. It also means a holder of the endpoint secret can learn whether a given pair exists in the endpoint's organization. The secret already allows launching runs against that organization's devices, so this adds no capability it did not have; treat the secret accordingly and rotate it if it leaks.
Required Headers (all auth modes):
| Header | Description |
|---|---|
Content-Type | application/json |
Required Headers (signature-based auth: hmac_sha256):
| Header | Description |
|---|---|
X-Hegemony-Signature | HMAC-SHA256 hex digest |
X-Hegemony-Timestamp | Unix epoch seconds (integer) |
Required Headers (bearer token auth: bearer):
| Header | Description |
|---|---|
Authorization | Bearer <token> |
Optional Headers:
| Header | Description |
|---|---|
X-Idempotency-Key | Unique key for deduplication |
Response Codes:
| Code | Meaning |
|---|---|
202 Accepted | Trigger accepted, run creation initiated |
400 Bad Request | Linked flow has no committed version |
401 Unauthorized | Invalid signature or expired timestamp |
403 Forbidden | Source IP not allowed (IP-allowlist) |
404 Not Found | Unknown path token or disabled endpoint; pinned flow_version not found on the linked flow |
409 Conflict | Duplicate idempotency key |
413 Payload Too Large | Request body exceeds configured max size |
422 Unprocessable Entity | Trigger rejected: no linked flow ("No linked flow"), linked flow deleted, linked flow outside the endpoint's org (and not shared-org), or invalid payload |
429 Too Many Requests | Endpoint rate limit exceeded, or the request was shed by the ingress limit before processing |
503 Service Unavailable | Secret backend unavailable/unresolvable |
Success Response (202):
{
"delivery_id": "uuid",
"triggered_runs": [
{
"target_type": "flow",
"target_id": "uuid",
"target_name": "Deploy Flow",
"flow_id": "uuid",
"flow_name": "Deploy Flow",
"run_id": "uuid",
"workflow_id": "string"
}
]
}The envelope is unchanged from the fanout era, but triggered_runs now always carries exactly one element — the run started for the endpoint's single linked flow. target_type is always "flow" (WebhookTargetType).
5. Security
HMAC Verification Flow
Signature Algorithm
signature = HMAC-SHA256(
key = resolved_secret_bytes,
msg = f"{timestamp}.{raw_body}".encode("utf-8")
).hexdigest()Where:
timestamp= value ofX-Hegemony-Timestampheader (string, Unix epoch seconds)raw_body= raw request body bytes decoded as UTF-8- Comparison uses
hmac.compare_digest()(constant-time)
Secret Handling
- Webhook HMAC secrets are stored via the existing
Secretmodel and resolved through whichever secret backend the deployment configures. WebhookEndpoint.secret_idis a FK tosecrets.id.- At verification time, the service resolves the secret value through the
SecretBackendinfrastructure. - Rotation is a cutover: rotating repoints the endpoint at the new secret, and only that secret verifies from then on. Update senders before rotating, or deliveries signed with the old secret are rejected.
Rate Limiting
Two budgets run at different points in the request, for different reasons.
Delivery limit — check_rate_limit, rate_limit_per_minute per endpoint:
- In-memory per-endpoint token bucket — plain synchronous code built on
time.monotonic(); the event loop's single-threaded execution keeps it safe without any locking. - Stored as
dict[UUID, _RateLimitBucket]in module scope. - Limits are tracked in memory per API instance; counters reset on restart and are not shared across instances.
- Runs after authentication and the idempotency check, so it only meters requests that are going to create a delivery.
Ingress limit — check_ingress_limit, charged in the router right after the endpoint lookup and before every other cost:
- Because the delivery limit runs fourth, every rejection ahead of it is unmetered: a replayed idempotency key costs a
SELECTand returns 409 forever, and a bad signature, missing header, stale timestamp or secret that resolves to nothing each costs acreate_deliverywrite and returns 401. (A secret backend failure is different:_resolve_webhook_secret_valueraises 503 in the router before the service is reached, so no delivery row is written for it.) Replaying one captured signed request was therefore unbounded work. - The budget's size is
rate_limit_per_minute × multiplier, so it cannot be charged until the endpoint row is loaded: a flood still costs one indexedSELECTeach. What it is charged before is the body read and the secret resolution, so shed traffic reaches neither the secret backend nor a delivery write. (Skipping thatSELECTtoo is what the global backstop below is for.) No delivery row is recorded for a shed request — writing one is the cost the guard exists to avoid, so shed traffic does not appear in the deliveries list. - Sized as
rate_limit_per_minute × webhook_ingress_limit_multiplier(default10), in a separate bucket registry. Both properties are deliberate: a shared registry would charge an accepted request twice and make endpoints reject at half their configured rate, and a budget at or below the delivery limit would convert ordinary retries into 429. - Ordinary traffic is unaffected. A client retrying a delivery still receives
409 Conflictwith the originaldelivery_id— the answer it needs — rather than being shed. - Process-local, with the same caveat as the delivery limit: shedding is not shared across replicas, so multi-instance deployments still want an external limiter in front.
Global backstop — check_global_ingress_limit, webhook_global_ingress_per_minute (default 6000):
- The per-endpoint ingress budget is sized from
rate_limit_per_minute, so it cannot be charged until the endpoint row is loaded — a flood aimed at the public path still costs one indexedSELECTeach. This ceiling is charged before that lookup, so the query is skipped too. - One bucket shared by every endpoint by construction, so it is an aggregate ceiling rather than a per-endpoint throughput limit: 6000/minute is 100 lookups per second for the whole API instance, not per endpoint. It has to be sized above the aggregate rate and its bursts, or one busy endpoint sheds another's.
- Session acquisition from the pool still happens (it is a FastAPI dependency), but no query is issued.
6. Idempotency & Concurrency
Dedupe Key Derivation
- If
X-Idempotency-Keyheader is present → use its value. - Otherwise → no idempotency enforcement (each request creates a new delivery).
DB Constraint
UNIQUE (webhook_endpoint_id, idempotency_key)On conflict: return 409 Conflict with the existing delivery_id and representative run_id (if available).
Webhook Trigger Execution
Webhook-triggered execution resolves the endpoint's single linked flow and calls create_run_from_request() once:
- The response's
triggered_runs[]carries exactly one item for the started run. - A webhook that must start several flows targets one parent flow whose graph runs the children as nested child flows (
flow.runsteps) — fanout lives in the flow graph, not the webhook layer.
7. Observability
Structured Log Fields
Every webhook-related log entry includes:
{
"webhook_endpoint_id": "uuid",
"delivery_id": "uuid",
"correlation_id": "uuid (same as delivery_id)",
"run_ids": ["uuid"],
"source_ip": "string",
"outcome": "accepted | rejected | duplicate | error"
}Audit Events
| Action | Event Type | Resource Type |
|---|---|---|
| Create endpoint | entity.created | webhook_endpoint |
| Update endpoint | entity.updated | webhook_endpoint |
| Delete endpoint | entity.deleted | webhook_endpoint |
| Restore endpoint | entity.restored | webhook_endpoint |
| Rotate secret | entity.updated | webhook_endpoint |
| Trigger (success) | webhook.triggered | webhook_endpoint |
| Trigger (rejected) | webhook.rejected | webhook_endpoint |
| Run started by a trigger | run.started | run |
The trigger endpoint is public: the request is authenticated by its HMAC-SHA256 signature or bearer token, but no user identity is behind it. Its audit entries record the client address masked to the network (the last IPv4 octet, or the IPv6 /64) as source_ip_masked, and name the actor system:webhook rather than a person. The structured log above is a separate record and keeps the full address under source_ip, so the two fields are not interchangeable.
A restore carries the endpoint as it stands afterwards, so the entry says what came back rather than only that something did.
A successful trigger records two entries because it changes two objects: one on the endpoint and one on the run it started. The run's entry carries trigger_source: webhook in its details, so a webhook-started run is told apart from a manual or scheduled one by that detail rather than by a separate verb — every run start is the start action.
8. Calling a Webhook Endpoint
Repeatable examples for calling a webhook endpoint in both auth modes (HMAC and bearer).
Prerequisites (applies to both modes)
- Create a webhook endpoint in UI or via
POST /webhooks. - Capture the generated
path_token. - Ensure the endpoint links a valid flow (
flow_id). - Use a unique
X-Idempotency-Keyper test call unless intentionally testing duplicate handling. - Create a secret for the auth mode and link the webhook to it. The endpoint reads the secret's folder through the secret's own backend and takes the first of the keys
value,secret,hmac,hmac_key,token,keyit finds — or the only key, when the folder holds exactly one. Forhmac_sha256that value is the signing key; forbearerit is the token callers send. A Configuration Exchange bundle links the two withsecret_ref: <secret name>; the name is unique within the organization. - For local testing two paths can be used:
- API route:
POST http://localhost:8000/hooks/{path_token} - UI route:
POST http://localhost:5173/api/hooks/{path_token}(note: CORS may apply, use API route for auth testing to avoid CORS issues)
- API route:
- In production, use the northbound app URL under
/api:POST https://<app-domain>/api/hooks/{path_token}
Local vs Production routing behavior
Local development
- Direct API call:
http://localhost:8000/hooks/{path_token} - UI/dev-server proxy call:
http://localhost:5173/api/hooks/{path_token} - Vite proxies
/api/*to API and rewrites/apiaway, so/api/hooks/...becomes/hooks/...on the backend.
- Direct API call:
Production
- UI is served by nginx, and northbound API calls are made via
/api/*on the app domain. - nginx proxies
/api/*to the API service (http://api:8000/*), so externally-visiblePOST /api/hooks/{path_token}maps to backendPOST /hooks/{path_token}.
- UI is served by nginx, and northbound API calls are made via
- API prefix convention
- Hegemony's northbound convention is
/api— there is no/api/v1prefix for webhook routes.
- Hegemony's northbound convention is
Bearer mode test
Configure endpoint with:
auth_mode = bearersecretpointing to a secret containing the expected bearer token value
Send request:
BODY='{"event":"deploy","env":"dev"}'
WEBHOOK_BEARER_TOKEN='REPLACE_WITH_BEARER_TOKEN'
IDEMPOTENCY_KEY='bearer-test-001'
WEBHOOK_PATH_TOKEN='{path_token}'
curl -i -X POST "http://localhost:8000/hooks/${WEBHOOK_PATH_TOKEN}" \
-H "Authorization: Bearer ${WEBHOOK_BEARER_TOKEN}" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: ${IDEMPOTENCY_KEY}" \
-d "$BODY"Expected outcomes:
202 Acceptedwhen token matches and target trigger succeeds.401 Unauthorizedfor missing/invalidAuthorizationheader.409 Conflictwhen reusing the same idempotency key for the same endpoint.429 Too Many Requestsif endpoint rate limit is exceeded.
Notes:
- Bearer mode requires
Authorization: Bearer <token>and does not requireX-Hegemony-Timestamp/X-Hegemony-Signature. - HMAC mode requires both
X-Hegemony-TimestampandX-Hegemony-Signature. - Hegemony does not inject inbound webhook headers for caller requests; caller must provide headers.
Recipe: a NetBox event rule starts a flow against the device it is about
NetBox signs its own webhooks with X-Hook-Signature (HMAC-SHA512 over the body, no timestamp), which Hegemony's hmac_sha256 mode cannot verify. Bearer mode can be driven from NetBox as it is, because NetBox lets a webhook carry extra headers and render its own body. Together with provider references in related_targets, that is enough for a change in NetBox to start a flow against the device the change is about, with nothing to install on either side.
- In Hegemony, create a webhook endpoint with
auth_mode = bearer, link the flow it should start, and give it a secret holding the token. Note the endpoint'spath_token. - In NetBox, create a Webhook (Operations → Integrations → Webhooks):
URL:
https://<hegemony>/api/hooks/<path_token>HTTP method:
POST, HTTP content type:application/jsonAdditional headers, one per line:
textAuthorization: Bearer <token> X-Idempotency-Key: {{ request.id }}NetBox renders the headers and the body with Jinja2 from the same context. The request id makes each delivery its own idempotency key, so a NetBox retry of the same event is answered
409rather than starting a second run. (Older NetBox releases expose it asrequest_id.)Body template:
json{ "params": { "event": "{{ event }}", "netbox_device": "{{ data.name }}" }, "related_targets": { "targets": [ {"provider_id": "netbox:primary", "external_id": "{{ data.id }}"} ] } }data.idmust be quoted:external_idis text on both sides, and an unquoted integer is rejected with422.provider_idis the id of the NetBox provider in Hegemony —netbox:<name>in the default organization,netbox:<name>:<org uuid>in any other (see "provider references" above).
- In NetBox, create an Event Rule (Operations → Integrations → Event Rules) for object type
dcim | device, eventObject updated(and any other events you want), action type Webhook, action object the webhook from step 2. A condition (for example onstatus) keeps ordinary edits from starting runs.
Two limits, stated plainly:
- A device created in NetBox cannot be targeted until the next provider sync. The reference resolves against Hegemony's own inventory, and a device NetBox has just created is not in it yet, so an
Object createdrule that targets{{ data.id }}is rejected with the400above until the sync runs. Trigger onObject updated, or use the event to start a flow with no device target. payload_mapping_jsonis stored but not applied. The endpoint carries it (see the schema above) and Configuration Exchange round-trips it, but the trigger path does not read it: the body'sparamsare the run's inputs as sent. Render the parameters you need in the body template instead.
HMAC mode test
Configure endpoint with:
auth_mode = hmac_sha256secretpointing to the signing secret
Signature formula:
signature = HMAC_SHA256(secret, "{timestamp}.{raw_body}") (hex digest)
Send request:
BODY='{"event":"deploy","env":"dev"}'
TS=$(date +%s)
SECRET='<hmac-secret>'
SIG=$(printf '%s.%s' "$TS" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | xxd -p -c 256)
WEBHOOK_PATH_TOKEN='{path_token}'
IDEMPOTENCY_KEY='hmac-test-001'
curl -i -X POST "http://localhost:8000/hooks/${WEBHOOK_PATH_TOKEN}" \
-H "X-Hegemony-Timestamp: $TS" \
-H "X-Hegemony-Signature: $SIG" \
-H "Content-Type: application/json" \
-H "X-Idempotency-Key: ${IDEMPOTENCY_KEY}" \
-d "$BODY"Expected outcomes:
202 Acceptedwhen signature is valid and timestamp is within replay window.401 Unauthorizedfor missing headers, invalid signature, or replay-window violation.409 Conflictfor duplicate idempotency key.
Replay-window check (HMAC only):
- Request is accepted only when
abs(now_utc_epoch - X-Hegemony-Timestamp) <= replay_window_seconds. - Too-old or too-far-in-future timestamps are rejected.
Quick negative tests checklist
- Bearer: wrong token →
401 - HMAC: tamper body after signing →
401 - HMAC: stale timestamp (older than replay window) →
401 - Any mode: repeated
X-Idempotency-Keyon same endpoint →409 - Any mode: source IP outside
allowed_source_ips(if configured) →403