# Webhook

Send events via HTTP POST to a URL endpoint. Outpost supports two webhook modes:

* Default mode — Customizable headers and signature format
* Standard Webhooks mode — Follows the [Standard Webhooks](https://www.standardwebhooks.com/) specification

## Creating a Webhook Destination

```sh
curl 'https://api.outpost.hookdeck.com/2025-07-01/tenants/<TENANT_ID>/destinations' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <API_KEY>' \
--data '{
  "type": "webhook",
  "topics": ["user.created", "user.updated"],
  "config": {
    "url": "https://example.com/webhooks"
  }
}'

```

## Configuration

### Config

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `config.url` | string | Yes | The URL to send events to |
| `config.custom_headers` | string | No | JSON object of custom HTTP headers to include |

### Credentials

| Field | Type | Required | Description |
| --- | --- | --- | --- |
| `credentials.secret` | string | No | Signing secret — auto-generated if not provided |
| `credentials.previous_secret` | string | No | Previous secret during a rotation window |
| `credentials.previous_secret_invalid_at` | string | No | RFC 3339 timestamp when the previous secret expires |

If `secret` is not provided, one is auto-generated. Tenants can trigger secret rotation but cannot set secrets directly.

## Event Format

When you publish an event:

```json
{
  "topic": "user.created",
  "data": { "user_id": "usr_123", "email": "user@example.com" },
  "metadata": { "source": "signup-service" }
}

```

Outpost sends an HTTP POST request:

```
POST /webhooks HTTP/1.1
Content-Type: application/json
x-outpost-event-id: evt_abc123
x-outpost-topic: user.created
x-outpost-timestamp: 2024-06-01T08:23:36Z
x-outpost-signature: v0=abc123def456...
x-outpost-source: signup-service

{"user_id": "usr_123", "email": "user@example.com"}

```

The request body contains the event's `data` field as JSON. The `metadata` field is translated to headers using the configured prefix.

### Event ID header and idempotency

Webhook delivery is at-least-once (see [Event delivery & retries](/docs/outpost/features/event-delivery)). Your handler should deduplicate using the event id — the same stable `id` you set when [publishing](/docs/outpost/publishing/events) (Outpost may redeliver on retries).

In default mode, that id is sent as the system `event-id` metadata field. The HTTP header name is `{header_prefix}{metadata-key}` with no extra separator — the prefix and key are concatenated as-is. So the default prefix `x-outpost-` plus `event-id` yields `X-Outpost-Event-Id`; a prefix without a trailing separator (for example `x-acme`) would produce `x-acme` + `event-id` → `X-Acmeevent-Id` (Go canonicalizes the wire name). Include a trailing hyphen in the prefix when you want a conventional shape like `X-Acme-Event-Id`. Change the prefix with `DESTINATIONS_WEBHOOK_HEADER_PREFIX`, or omit this header with `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER`.

In Standard Webhooks mode, the same value is sent as the `webhook-id` header (default prefix `webhook-`, so typically `Webhook-Id`) per the [Standard Webhooks](https://www.standardwebhooks.com/) specification.

## Signatures

### Default Mode

The signature is computed over the request body:

```
HMAC-SHA256(secret, "${body}")

```

The `x-outpost-signature` header value follows the format: `v0=${signature}`

If the destination has an unexpired `previous_secret` during secret rotation, Outpost includes one signature for each valid secret in the same header. The current secret's signature is first, followed by the previous secret's signature:
`v0=<signature-from-current-secret>,<signature-from-previous-secret>`

To verify:

1. Extract the signature header
2. Split the `v0=` value on commas
3. Compute the expected signature using each active secret you accept
4. Accept the request if any signature matches using a constant-time comparison
5. Optionally reject requests with old timestamps to prevent replay attacks

#### Custom signature templates

In default mode, operators can customize both the signed content and the signature header value:

* `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` controls the string passed to HMAC.
* `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE` controls the value written to the signature header.

These use the same configuration key names in both deployment models, but they are applied differently: Managed Outpost stores them through the Config API or Hookdeck dashboard, while self-hosted Outpost reads them from environment variables or YAML configuration.

Templates use Go template syntax with helper functions such as `join`. The signature content template can use:

| Field | Description |
| --- | --- |
| `.EventID` | Event id used for the delivery |
| `.Topic` | Event topic |
| `.Timestamp` | Delivery timestamp |
| `.Body` | Raw request body |

The signature header template can use the same metadata plus `.Signatures`, which is the list of generated signatures for all valid secrets. During secret rotation, `.Signatures` contains the current secret's signature first and the previous secret's signature second.

For example, to include a Unix timestamp in the signed content and header:

### Managed
Set the values in the [Config API](/docs/outpost/api#configuration) or in [Hookdeck Destinations settings](https://dashboard.hookdeck.com/settings/project/destinations). For the Config API, send the configuration keys in the JSON body:
```json
{
  "DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE": "{{.Timestamp.Unix}}.{{.Body}}",
  "DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE": "t={{.Timestamp.Unix}},v0={{.Signatures | join \",\"}}"
}

```

### Self-Hosted
Set the values as environment variables:
```sh
DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE="{{.Timestamp.Unix}}.{{.Body}}"
DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE="t={{.Timestamp.Unix}},v0={{.Signatures | join \",\"}}"

```

You can also set `signature_content_template` and `signature_header_template` in YAML under `destinations.webhook`.

With one secret, the header looks like:

```
x-outpost-signature: t=1717249416,v0=<signature-from-current-secret>

```

During rotation, the same template preserves both signatures:

```
x-outpost-signature: t=1717249416,v0=<signature-from-current-secret>,<signature-from-previous-secret>

```

If you customize `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE`, keep `.Signatures` in the output so receivers can verify requests during secret rotation.

### Standard Webhooks Mode

Follows the [Standard Webhooks specification](https://www.standardwebhooks.com/):

```
base64(HMAC-SHA256(secret, "${webhook-id}.${timestamp}.${body}"))

```

Use the official [Standard Webhooks SDK](https://github.com/standard-webhooks/standard-webhooks/tree/main/libraries) to verify signatures. Secrets use the `whsec_<base64>` format.

During secret rotation, Outpost includes one Standard Webhooks signature entry per valid secret in the `webhook-signature` header. The current secret's signature is first, followed by the previous secret's signature:

```
webhook-signature: v1,<signature-from-current-secret> v1,<signature-from-previous-secret>

```

### Managed
Enable Standard Webhooks mode by setting `DESTINATIONS_WEBHOOK_MODE=standard` in the Config API or in [Hookdeck Destinations settings](https://dashboard.hookdeck.com/settings/project/destinations).

### Self-Hosted
Enable Standard Webhooks mode:
```
DESTINATIONS_WEBHOOK_MODE=standard

```

## Secret Rotation

Rotate a webhook secret without downtime. During the rotation window, both the old and new secrets produce valid signatures.

```sh
curl --request PATCH \
'https://api.outpost.hookdeck.com/2025-07-01/tenants/<TENANT_ID>/destinations/<DESTINATION_ID>' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <API_KEY>' \
--data '{
  "credentials": {
    "rotate_secret": "true",
    "previous_secret_invalid_at": "2025-01-15T00:00:00Z"
  }
}'

```

When rotation is triggered:

1. The current secret becomes `previous_secret`
2. A new secret is generated
3. The previous secret remains valid until `previous_secret_invalid_at` (default: 24 hours)
4. During the rotation window, the signature header contains signatures generated with both valid secrets
5. After `previous_secret_invalid_at`, the previous secret is no longer included and the signature header returns to a single signature

Signature header format depends on the webhook mode. With the default header prefix:

| Mode | Header | During rotation |
| --- | --- | --- |
| Default | `x-outpost-signature` | `v0=<signature-from-current-secret>,<signature-from-previous-secret>` |
| Standard Webhooks | `webhook-signature` | `v1,<signature-from-current-secret> v1,<signature-from-previous-secret>` |

Receivers should treat rotation as an allow-list period: verify the request against the current secret and the previous secret, and accept the request if any signature in the header matches one of those secrets. Outpost signs with the current secret first.

## Custom Headers

Tenants can add custom HTTP headers to webhook requests for authentication or routing:

```sh
curl 'https://api.outpost.hookdeck.com/2025-07-01/tenants/<TENANT_ID>/destinations' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer <API_KEY>' \
--data '{
  "type": "webhook",
  "topics": ["*"],
  "config": {
    "url": "https://example.com/webhooks",
    "custom_headers": "{\"x-api-key\": \"secret123\"}"
  }
}'

```

Header names must start with a letter or digit and may contain letters, digits, underscores, and hyphens. The following headers cannot be overridden: `content-type`, `content-length`, `host`, `connection`, `user-agent`.

### Managed
Custom webhook headers in the tenant portal are disabled by default. Enable them in [Hookdeck User Portal settings](https://dashboard.hookdeck.com/settings/project/user-portal).

### Self-Hosted
Custom headers are disabled in the tenant portal by default. Enable with:
```
PORTAL_ENABLE_WEBHOOK_CUSTOM_HEADERS=true

```

## Operator Configuration

### Managed
Configure webhook operator behavior using these keys in the Config API or in [Hookdeck Destinations settings](https://dashboard.hookdeck.com/settings/project/destinations): `DESTINATIONS_WEBHOOK_HEADER_PREFIX`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER`, `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER`, `DESTINATIONS_WEBHOOK_MODE`, `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM`, `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING`, `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE`, and `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE`.

### Self-Hosted
### Header Settings| Variable | Default | Description |
| --- | --- | --- |
| `DESTINATIONS_WEBHOOK_HEADER_PREFIX` | `x-outpost-` / `webhook-` | Prefix for system webhook headers (event id, topic, timestamp, signature). Unless overridden, defaults to `x-outpost-` when `DESTINATIONS_WEBHOOK_MODE` is `default` and `webhook-` when `standard`. |
| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_EVENT_ID_HEADER` | `false` | Disable the event ID header |
| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TIMESTAMP_HEADER` | `false` | Disable the timestamp header |
| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_TOPIC_HEADER` | `false` | Disable the topic header |
| `DESTINATIONS_WEBHOOK_DISABLE_DEFAULT_SIGNATURE_HEADER` | `false` | Disable the signature header |### Signature Settings| Variable | Default | Description |
| --- | --- | --- |
| `DESTINATIONS_WEBHOOK_MODE` | `default` | Set to `standard` for Standard Webhooks compliance |
| `DESTINATIONS_WEBHOOK_SIGNATURE_ALGORITHM` | `hmac-sha256` | Signature algorithm |
| `DESTINATIONS_WEBHOOK_SIGNATURE_ENCODING` | `hex` | Encoding: `hex` or `base64` |
| `DESTINATIONS_WEBHOOK_SIGNATURE_CONTENT_TEMPLATE` | `{{.Body}}` | Template for signed content in default mode |
| `DESTINATIONS_WEBHOOK_SIGNATURE_HEADER_TEMPLATE` | `v0={{.Signatures | join ","}}` | Template for signature header value in default mode |