Appearance
Manual webhook
Tenant manual webhooks let customers POST arbitrary JSON when no vendor adapter exists — internal runbooks, glue scripts, or custom monitoring. Each tenant gets a unique HTTPS URL and HMAC signing secret.
See Sources for how manual webhooks fit with the other incoming webhooks. Each named endpoint occupies one primary or supporting incoming slot. Trial and Starter include 1 named endpoint; Growth allows 5; Business allows 15; Enterprise is unlimited. Each endpoint has its own URL token, signing secret, field-path map, and Pulse source role.
On the hosted app, use the URL Connections copies. It starts with https://exhaleoncall.com/webhooks/. The customer host does not publish /v1/webhooks/....
Endpoint
After Connect in Connections → Sources → Custom ingest → Manual webhook:
Preferred (keeps tokens out of proxy access logs):
POST https://exhaleoncall.com/webhooks/manualSend header X-Exhale-Webhook-Token: {token}.
Legacy path form (still supported):
POST https://exhaleoncall.com/webhooks/manual/{token}token— opaque URL segment (not a secret; the signing secret authenticates deliveries).- Response: 202 Accepted with
pulse_id,status,created, andmessage.
Unknown tokens return 404. Disabled or expired-trial tenants return 403.
Customer setup
- Sign in as admin → Connections → Sources → Custom ingest → Manual webhook.
- Click Connect — the signing secret is shown once; copy it to your sender.
- Copy the webhook URL from the card. The card matches Edit settings: named-endpoint dropdown above a bordered block with the endpoint name heading, then Webhook URL (POST). Use View docs for the JSON envelope and curl examples (
external_idrequired). - Optional: Edit settings opens a scrollable dialog (wider than other Edit settings dialogs; Pulse source role is the first row in the well — bordered group with the label above the dropdown and role copy to its right; Disconnect is on the footer left with Close / Save & close on the right, same chrome as other Edit settings). The named-endpoint dropdown sits above a bordered block for the selected endpoint (name heading through collapsed Advanced... field-path mapping, plus {refresh} Rotate signing secret and {trash} Remove endpoint). Remove endpoint confirms in a dialog with a trash icon before the title and before the label; that button uses the same colors as Edit settings Disconnect, at the confirm-button size. A dropdown selects the URL to copy; Add another named endpoint is the last option (disabled at the plan cap). Growth or higher workspaces can add more named URLs until the cap.
- Send test alert creates a synthetic pulse and marks the integration configured.
Rotate signing secret replaces the previous secret right away.
Default JSON envelope
| Field | Required | Default path | Notes |
|---|---|---|---|
external_id | Yes | external_id | Stable id for dedupe (source=other) |
title | No | title | Defaults to Manual alert {external_id} |
event_type | No | event_type | triggered (default), acknowledged, or resolved |
source_url | No | source_url | Deep link in the UI (https only, no user:pass@ credentials) |
delivery_id | No | delivery_id | Replay dedupe id; may also be sent as header |
severity | No | severity (or priority) | Normalized onto the pulse (critical / high / medium / low / unknown) |
urgency | No | urgency | Normalized high / low / unknown |
service / service_name | No | service_name, else service | Display name on list/detail |
alert_key | No | alert_key | Stored when it differs from external_id |
Signed curl example
bash
TOKEN="your-url-token"
SECRET="your-signing-secret"
BODY='{"external_id":"MANUAL-1","title":"Disk full on db-01","event_type":"triggered"}'
SIG="v1=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
# Preferred — token in header
curl -i -X POST "https://exhaleoncall.com/webhooks/manual" \
-H "Content-Type: application/json" \
-H "X-Exhale-Webhook-Token: ${TOKEN}" \
-H "X-Exhale-Signature: $SIG" \
-d "$BODY"
# Legacy — token in URL path
curl -i -X POST "https://exhaleoncall.com/webhooks/manual/${TOKEN}" \
-H "Content-Type: application/json" \
-H "X-Exhale-Signature: $SIG" \
-d "$BODY"Field path mapping
When payloads nest fields, admins configure dot-paths in the Integrations UI or via:
GET /auth/sources/manual-webhook/field-pathsPUT /auth/sources/manual-webhook/field-paths(admin + CSRF)
Example paths:
json
{
"external_id": "alert.id",
"title": "alert.summary",
"event_type": "alert.status",
"source_url": "alert.url",
"delivery_id": "meta.delivery_id"
}Signature verification
Deliveries must include X-Exhale-Signature: HMAC-SHA256 of the raw body as v1=<hex>. Missing or invalid signatures receive 401.
There is no timestamp in the signature header — use delivery_id (body or X-Exhale-Delivery-Id header) for replay protection.
Dedupe and reliability
Same pipeline limits as PagerDuty ingest:
- Bodies larger than the configured maximum (default 1 MiB) → 413
- Invalid or missing
Content-Length→ 400 - Per-IP rate limits → 429 with
Retry-After - Unavailable dedupe storage can return 503
Lifecycle mapping
event_type | Pulse status | Triage |
|---|---|---|
triggered (default) | received | Enqueued when async triage is enabled |
acknowledged | acknowledged | No new triage enqueue |
resolved | resolved | No new triage enqueue |
Duplicate (tenant_id, source=other, external_id) updates the existing row and returns 202 with created: false.
Scrubbing and logging
Parsed JSON is scrubbed before persist (same pipeline as PagerDuty). Application logs record pulse_id, correlation_id, source, and external_id only — never the raw body.
Stored pulses use source=other; the UI labels them Manual webhook.
Auth API (customer)
Admin session and CSRF on mutating routes in the app.
| Route | Purpose |
|---|---|
POST …/connect | Issue URL token + signing secret (once) |
POST …/rotate-secret | New signing secret (once) |
POST …/test-alert | Synthetic ingest + mark configured |
GET / PUT …/field-paths | Read/update dot-path mapping |
GET /auth/sources includes webhook_path for connected manual webhooks; never exposes the signing secret.