Skip to content

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 ModeSourceEndpoint
ManualUser (UI / curl)POST /runs
ScheduledTimer (polling worker)GET /internal/schedules/due → POST /internal/schedules/{id}/trigger
WebhookExternal 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 CaseTrigger Type
Trigger a flow directly (e.g., from CI/CD or monitoring alert)flow
Pass payload params/targets to run inputsflow

2. Architecture ​

Inbound Webhook Sequence (Current) ​

Component Placement ​


3. Domain Model ​

ER Diagram ​

Enums (packages/core/enums.py) ​

python
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_id column (strict N:1, mirroring Schedule.flow_id).
  • WebhookTargetType (single value flow) survives as response vocabulary: it types WebhookTriggeredRun.target_type in 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.run steps) run the children.

WebhookEndpoint Model (apps/api/models.py) ​

Uses TimestampMixin, AuditMixin, SoftDeleteMixin, OrgScopedMixin — same pattern as other managed resources.

ColumnTypeConstraintsNotes
idUUIDPK, default uuid4
nameString(255)NOT NULLHuman-readable name
descriptionTextnullable
enabledBooleanNOT NULL, default TrueFollows NotificationDestination pattern
path_tokenString(64)NOT NULL, UNIQUEGenerated via secrets.token_urlsafe(32)
secret_idUUIDFK → secrets.id, SET NULLReuses existing Secret model
auth_modeEnum(WebhookAuthMode)NOT NULL, default hmac_sha256
replay_window_secondsIntegerNOT NULL, default 300Reject timestamps outside window
rate_limit_per_minuteIntegerNOT NULL, default 60Per-endpoint rate limit
flow_idUUIDFK → flow_definitions.id, SET NULL, indexed, nullableThe single linked flow (mirrors Schedule.flow_id)
flow_versionIntegernullablePinned committed version; NULL = latest committed
allowed_source_ips_jsonJSONBnullableOptional IP allowlist ["10.0.0.0/8"]
payload_mapping_jsonJSONBdefault {}Reserved; not read by the trigger path
last_triggered_atDateTimeTZnullableLast successful trigger time
+ TimestampMixincreated_at, updated_at
+ AuditMixincreated_by, updated_by
+ SoftDeleteMixindeleted_at, deleted_by
+ OrgScopedMixinorg_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 with 422 "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 NULL is the database-level backstop for out-of-band deletions only.
  • History: earlier phases used an N:N webhook_endpoint_targets association table (WebhookEndpointTarget model). Nested child flows (flow.run steps) made webhook-level fanout redundant, so migration 20260824_039_webhook_single_flow.py replaced the association with the single flow_id column. 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.

ColumnTypeConstraintsNotes
idUUIDPK, default uuid4
webhook_endpoint_idUUIDFK → webhook_endpoints.id, CASCADE, NOT NULL
idempotency_keyString(255)nullableFrom the X-Idempotency-Key header only; absent when the caller sends none
signature_validBooleanNOT NULLWhether HMAC verification passed
source_ipString(45)nullableCaller IP (IPv4 or IPv6)
request_body_hashString(64)nullableSHA-256 hex digest (never raw body)
response_statusIntegerNOT NULLHTTP status returned
error_detailTextnullableError message if failed
run_idsJSONBNOT NULL, default []Array of run UUIDs created by this delivery. New deliveries carry exactly one element; historical fanout rows may hold several
processing_duration_msIntegernullableTotal processing time
created_atDateTimeTZNOT NULL

Constraints:

  • UniqueConstraint("webhook_endpoint_id", "idempotency_key", name="uq_webhook_deliveries_endpoint_idempotency") — dedupe enforcement
  • Index("ix_webhook_deliveries_endpoint_id", "webhook_endpoint_id") — fast delivery log lookups
  • Index("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):

json
{
  "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:

json
{
  "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:

json
{
  "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; 400 if the flow has no committed version
    • pinned committed version → uses the configured committed version and rejects the request with 404 if 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 params and/or related_targets keys are honored explicitly: params must be an object, related_targets maps 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:

json
{
  "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:primary plus a small integer can be.
  • At most 100 provider references per request, counted across all roles.
  • At most HEGEMONY_INVENTORY_MAX_RUN_TARGETS targets in total (UUIDs and references together, default 10000) — a run may not have more targets than that anyway, and the request is rejected with 422 before 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):

HeaderDescription
Content-Typeapplication/json

Required Headers (signature-based auth: hmac_sha256):

HeaderDescription
X-Hegemony-SignatureHMAC-SHA256 hex digest
X-Hegemony-TimestampUnix epoch seconds (integer)

Required Headers (bearer token auth: bearer):

HeaderDescription
AuthorizationBearer <token>

Optional Headers:

HeaderDescription
X-Idempotency-KeyUnique key for deduplication

Response Codes:

CodeMeaning
202 AcceptedTrigger accepted, run creation initiated
400 Bad RequestLinked flow has no committed version
401 UnauthorizedInvalid signature or expired timestamp
403 ForbiddenSource IP not allowed (IP-allowlist)
404 Not FoundUnknown path token or disabled endpoint; pinned flow_version not found on the linked flow
409 ConflictDuplicate idempotency key
413 Payload Too LargeRequest body exceeds configured max size
422 Unprocessable EntityTrigger 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 RequestsEndpoint rate limit exceeded, or the request was shed by the ingress limit before processing
503 Service UnavailableSecret backend unavailable/unresolvable

Success Response (202):

json
{
  "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 ​

text
signature = HMAC-SHA256(
    key = resolved_secret_bytes,
    msg = f"{timestamp}.{raw_body}".encode("utf-8")
).hexdigest()

Where:

  • timestamp = value of X-Hegemony-Timestamp header (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 Secret model and resolved through whichever secret backend the deployment configures.
  • WebhookEndpoint.secret_id is a FK to secrets.id.
  • At verification time, the service resolves the secret value through the SecretBackend infrastructure.
  • 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 SELECT and returns 409 forever, and a bad signature, missing header, stale timestamp or secret that resolves to nothing each costs a create_delivery write and returns 401. (A secret backend failure is different: _resolve_webhook_secret_value raises 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 indexed SELECT each. 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 that SELECT too 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 (default 10), 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 Conflict with the original delivery_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 indexed SELECT each. 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 ​

  1. If X-Idempotency-Key header is present → use its value.
  2. Otherwise → no idempotency enforcement (each request creates a new delivery).

DB Constraint ​

sql
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.run steps) — fanout lives in the flow graph, not the webhook layer.

7. Observability ​

Structured Log Fields ​

Every webhook-related log entry includes:

json
{
  "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 ​

ActionEvent TypeResource Type
Create endpointentity.createdwebhook_endpoint
Update endpointentity.updatedwebhook_endpoint
Delete endpointentity.deletedwebhook_endpoint
Restore endpointentity.restoredwebhook_endpoint
Rotate secretentity.updatedwebhook_endpoint
Trigger (success)webhook.triggeredwebhook_endpoint
Trigger (rejected)webhook.rejectedwebhook_endpoint
Run started by a triggerrun.startedrun

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) ​

  1. Create a webhook endpoint in UI or via POST /webhooks.
  2. Capture the generated path_token.
  3. Ensure the endpoint links a valid flow (flow_id).
  4. Use a unique X-Idempotency-Key per test call unless intentionally testing duplicate handling.
  5. 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, key it finds — or the only key, when the folder holds exactly one. For hmac_sha256 that value is the signing key; for bearer it is the token callers send. A Configuration Exchange bundle links the two with secret_ref: <secret name>; the name is unique within the organization.
  6. 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)
  7. 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 /api away, so /api/hooks/... becomes /hooks/... on the backend.
  • 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-visible POST /api/hooks/{path_token} maps to backend POST /hooks/{path_token}.
  • API prefix convention
    • Hegemony's northbound convention is /api — there is no /api/v1 prefix for webhook routes.

Bearer mode test ​

Configure endpoint with:

  • auth_mode = bearer
  • secret pointing to a secret containing the expected bearer token value

Send request:

bash
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 Accepted when token matches and target trigger succeeds.
  • 401 Unauthorized for missing/invalid Authorization header.
  • 409 Conflict when reusing the same idempotency key for the same endpoint.
  • 429 Too Many Requests if endpoint rate limit is exceeded.

Notes:

  • Bearer mode requires Authorization: Bearer <token> and does not require X-Hegemony-Timestamp / X-Hegemony-Signature.
  • HMAC mode requires both X-Hegemony-Timestamp and X-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.

  1. 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's path_token.
  2. In NetBox, create a Webhook (Operations → Integrations → Webhooks):
    • URL: https://<hegemony>/api/hooks/<path_token>

    • HTTP method: POST, HTTP content type: application/json

    • Additional headers, one per line:

      text
      Authorization: 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 409 rather than starting a second run. (Older NetBox releases expose it as request_id.)

    • Body template:

      json
      {
        "params": {
          "event": "{{ event }}",
          "netbox_device": "{{ data.name }}"
        },
        "related_targets": {
          "targets": [
            {"provider_id": "netbox:primary", "external_id": "{{ data.id }}"}
          ]
        }
      }

      data.id must be quoted: external_id is text on both sides, and an unquoted integer is rejected with 422. provider_id is 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).

  3. In NetBox, create an Event Rule (Operations → Integrations → Event Rules) for object type dcim | device, event Object updated (and any other events you want), action type Webhook, action object the webhook from step 2. A condition (for example on status) 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 created rule that targets {{ data.id }} is rejected with the 400 above until the sync runs. Trigger on Object updated, or use the event to start a flow with no device target.
  • payload_mapping_json is 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's params are 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_sha256
  • secret pointing to the signing secret

Signature formula:

signature = HMAC_SHA256(secret, "{timestamp}.{raw_body}") (hex digest)

Send request:

bash
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 Accepted when signature is valid and timestamp is within replay window.
  • 401 Unauthorized for missing headers, invalid signature, or replay-window violation.
  • 409 Conflict for 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-Key on same endpoint → 409
  • Any mode: source IP outside allowed_source_ips (if configured) → 403

Released as open source under the AGPL-3.0-or-later license. Development is sponsored by Rexonix s.r.o.. Contact — [email protected].