Webhooks
Receive real-time HTTP callbacks when events happen in your Helpr workspace — new conversations, messages, emails, and more.
Overview
Webhooks let you subscribe to events in your Helpr organization. When a visitor starts a conversation, a message changes, an email arrives, or another subscribed event occurs, Helpr asynchronously sends an HTTPS POST to your endpoint.
Payloads are compact notifications: stable IDs, routing context, previews, and api_resources links. Fetch complete conversations, messages, attachments, or raw EML by following those links with an API key that has chats.read.
Webhook delivery is at least once, not exactly once. A receiver can see the same event more than once after an automatic retry or manual replay. Persist the top-level event id under a unique constraint before applying side effects.
Use webhooks to sync conversations and email threads to your CRM, trigger notifications in Slack, log messages to your data warehouse, or build custom integrations.
Delivery flow
- Journal. Helpr commits the event to an encrypted, immutable, 90-day journal and assigns its stable
evt_…ID and organizationsequence. - Subscription snapshot. Matching active and paused subscriptions, including their authorization version, are durably captured for that event. If the destination URL, scope, or event authorization later changes, the version check cancels stale queued work instead of sending it under the new authorization.
- Delivery queue. One delivery row is created for each captured endpoint. Overlapping workers use leases and idempotent enqueue keys, so a worker crash can be recovered without losing the event.
- HTTPS receiver. An active, still-authorized delivery is signed immediately before it is sent to the endpoint’s pinned public IP address.
Configured automation actions use the same journal and queue, but skip subscription matching: Helpr atomically records the event and its one explicitly selected, automation-only endpoint.
The journal and Events catch-up API use the same event ID. X-Helpr-Delivery identifies a delivery row: it stays the same across automatic attempts, while a manual replay receives a new delivery ID but retains the original event ID.
Ordering: do not assume HTTP requests arrive in sequence order. Retries, endpoint cooldowns, and parallel workers can allow a later event to arrive first. Use the numeric-string sequence to order or reconcile journal events within an organization; make each handler independently idempotent.
Setup
- Go to Settings → Developer in the Helpr dashboard
- Scroll to the Webhooks section
- Click Add Webhook
- Enter your HTTPS endpoint URL
- Choose This team only or, as an org admin, Entire organization
- Select the events you want to receive
- Click Create — copy and save the signing secret immediately
Your endpoint must use HTTPS and respond with a 2xx status code within 10 seconds.
Helpr sends only to a public destination: URL credentials and fragments are rejected, and private, loopback, link-local, carrier-grade NAT, documentation, multicast, or otherwise reserved IPv4/IPv6 addresses are blocked. At every attempt, every DNS answer must be publicly routable; Helpr pins a validated address for the connection, disables proxy use, and never follows redirects.
Team webhooks receive events for the selected team. Organization webhooks receive matching events from every team and personal inbox in the organization.
Important: The signing secret is only shown once when you create the webhook. You’ll need it to verify signatures on incoming requests.
Managing Webhooks
All webhook management is done in the Helpr dashboard under Settings → Developer → Webhooks:
- Pause / Resume — retain authorized queued work without sending it, then continue after resume
- Send test — queue a signed diagnostic
webhook.testpayload to verify your endpoint - Delivery log — inspect recent response codes, response bodies, attempts, latency, and retry state
- Replay delivery — queue a previous payload again with a new delivery ID
- Rotate secret — generate a new signing secret with a 7-day previous-secret signature grace period
- Emergency revoke — end an active previous-secret grace period immediately after every receiver uses the new secret
- Delete — permanently remove the webhook and its delivery history; migrate or remove automation references first
webhook.test is an endpoint diagnostic, not a subscription event: it is not written to the Events catch-up journal and may use the compact schema without an evt_… ID. Do not apply business side effects to test payloads.
| Status | Queue behavior |
|---|---|
| Active | Matching events are captured and due deliveries may be sent. |
| Paused | Matching events are still captured and queued, but they remain pending until the endpoint is resumed. |
| Disabled | No new subscription work is captured or attempted. Authorized pending work is retained and resumes after manual reactivation. Helpr auto-disables after 100 consecutive receiver failures. |
Paused and auto-disabled endpoints preserve authorized pending work. An expired processing lease returns to pending; a live lease is left to its owning worker, preventing a second worker from sending the same request concurrently. If that already-in-flight request succeeds after auto-disable, it clears the failure count and reactivates the endpoint; otherwise an administrator must reactivate it. Changing an endpoint’s destination URL, scope, or event authorization advances its authorization version: work queued under the old version is cancelled and is never sent or automatically replayed under the new authorization. This prevents an edited destination or narrowed team/event selection from silently receiving older data. An attempt that passed its final authorization preflight before an edit may still finish against the previous URL; it is never retargeted to the replacement URL and cannot change the replacement generation’s health state.
Secret rotation: install the newly displayed secret immediately. For the next 7 days Helpr signs with the current secret in X-Helpr-Signature-V1 and the previous secret in X-Helpr-Signature-Previous-V1. Keep the two values distinct in your receiver. After all instances use the current secret, use Emergency revoke to remove the previous secret early, or let the grace period expire automatically. A second rotation is blocked while a grace period is active.
chat.created
Fired when a visitor starts a new conversation (first message in a chat).
{
"id": "evt_18451",
"event": "chat.created",
"type": "chat.created",
"schema_version": "2026-07-13-events-v1",
"timestamp": "2026-07-13T14:32:10.123456Z",
"sequence": "18451",
"data": {
"chat_id": 4521,
"conversation_id": 4521,
"message_id": 18923,
"team_id": 3,
"visitor_id": "v_8f3a2b1c",
"identity": {
"email": "[email protected]",
"name": "Jane Smith",
"userId": "usr_123",
"company": "Acme Inc"
},
"api_resources": {
"conversation": {
"href": "https://helpr.so/api/v1/chats/get?id=4521&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
},
"messages": {
"href": "https://helpr.so/api/v1/chats/messages?id=4521&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
},
"message": {
"href": "https://helpr.so/api/v1/chats/message?id=18923&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
}
}
},
"context": {
"organization_id": 1,
"team_id": 3,
"secondary_team_id": null,
"inbox_id": null,
"chat_id": "4521",
"message_id": "18923",
"visibility": "team"
}
}
The identity object is included when the visitor has been identified via helpr.identify(). It contains whichever fields were set: email, name, userId, company, phone. If the visitor is anonymous, the identity field is omitted.
chat.message
Fired on every public message in a conversation — from visitors, agents, or bots.
{
"id": "evt_18452",
"event": "chat.message",
"type": "chat.message",
"schema_version": "2026-07-13-events-v1",
"timestamp": "2026-07-13T14:32:10.234567Z",
"sequence": "18452",
"data": {
"chat_id": 4521,
"conversation_id": 4521,
"message_id": 18923,
"sender_type": "visitor",
"sender_name": "Jane Smith",
"body": "Hi, I need help with my order",
"body_preview": "Hi, I need help with my order",
"body_format": "text",
"body_truncated": false,
"full_body_available": true,
"identity": {
"email": "[email protected]",
"name": "Jane Smith",
"userId": "usr_123"
},
"api_resources": {
"conversation": {
"href": "https://helpr.so/api/v1/chats/get?id=4521&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
},
"messages": {
"href": "https://helpr.so/api/v1/chats/messages?id=4521&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
},
"message": {
"href": "https://helpr.so/api/v1/chats/message?id=18923&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
}
}
},
"context": {
"organization_id": 1,
"team_id": 3,
"secondary_team_id": null,
"inbox_id": null,
"chat_id": "4521",
"message_id": "18923",
"visibility": "team"
}
}
| Field | Description |
|---|---|
chat_id | The conversation ID |
message_id | Unique message ID |
sender_type | visitor, agent, or bot |
sender_name | Display name of the sender |
body | Message text preview. Use api_resources.message for the full stored message |
body_truncated | true when the preview does not contain the full text body |
api_resources | Short-lived capability URLs that still require signed same-organization API-key authentication with chats.read |
identity | Visitor identity object (omitted if anonymous). See chat.created for available fields |
Email events
Email webhooks are delivered for shared email conversations and, through organization-scoped webhooks, personal inbox conversations. Subscribe to email.thread_created for the first inbound email in a thread, email.received for every inbound email, email.sent after Gmail or Microsoft confirms a send, and email.delivery_failed when an outbound email reaches a terminal failure.
{
"id": "evt_18453",
"event": "email.received",
"type": "email.received",
"schema_version": "2026-07-13-events-v1",
"timestamp": "2026-07-13T14:32:10.345678Z",
"sequence": "18453",
"data": {
"event_id": "email.received:18924",
"chat_id": 4521,
"conversation_id": 4521,
"team_id": 3,
"organization_id": 1,
"message_id": 18924,
"direction": "inbound",
"provider": "gmail",
"mailbox_state": "inbox",
"inbox": {
"id": 12,
"scope": "team",
"provider": "gmail",
"email": "[email protected]",
"name": "Support"
},
"sender": {
"type": "visitor",
"name": "Jane Smith"
},
"email": {
"subject": "Invoice question",
"from": { "email": "[email protected]", "name": "Jane Smith" },
"to": [{ "email": "[email protected]", "name": "Support" }],
"cc": [],
"bcc": [],
"body_format": "html",
"body_included": false,
"body_size_bytes": 56,
"provider_message_id": "18f80aa1a2b3c4d5",
"rfc_message_id": "<[email protected]>",
"attachments": [{
"id": "8ed3f6ad...",
"name": "invoice.pdf",
"mime": "application/pdf",
"size": 248193,
"api_resource": {
"href": "https://helpr.so/api/v1/chats/attachment?messageId=18924&attachmentId=8ed3f6ad...&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
}
}],
"raw_eml": {
"provider": "gmail",
"size_bytes": 129384,
"sha256": "f2c7...",
"api_resource": {
"href": "https://helpr.so/api/v1/chats/raw-eml?messageId=18924&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
}
},
"secure": false
},
"api_resources": {
"conversation": {
"href": "https://helpr.so/api/v1/chats/get?id=4521&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
},
"messages": {
"href": "https://helpr.so/api/v1/chats/messages?id=4521&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
},
"message": {
"href": "https://helpr.so/api/v1/chats/message?id=18924&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
},
"raw_eml": {
"href": "https://helpr.so/api/v1/chats/raw-eml?messageId=18924&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
}
}
},
"context": {
"organization_id": 1,
"team_id": 3,
"secondary_team_id": null,
"inbox_id": 12,
"chat_id": "4521",
"message_id": "18924",
"visibility": "team"
}
}
Breaking change: 2026-07-13 events-v1
Email webhook notifications no longer inline message bodies. The former email.body_preview, email.body_text, email.body_truncated, and email.full_body_available fields were removed; email.body_included is always false, and email.body_size_bytes reports the stored body’s size. Consumers that read the old fields must migrate to fetching the exact api_resources.message.href with signed API-key authentication as described under Fetch API resources. Do not interpret body_included: false as an empty message. During rollout, accept both the retained 2026-05-14-compact envelope and 2026-07-13-events-v1.
The message endpoint returns the complete canonical HTML or text body plus attachment metadata. It returns Helpr’s sanitized stored representation, not the byte-for-byte MIME source; fetch api_resources.raw_eml when the original RFC 822 message is required. Generic chat.message events still contain a bounded text preview; the body omission applies to email event data and email-triggered automation payloads.
The raw_eml object and api_resources.raw_eml link are present only when the original MIME message was archived. The raw EML and attachment resources return temporary signed download URLs.
Fetch API resources
The token in an api_resources.href does not authenticate the request. It is a short-lived, read-only capability for the exact conversation Helpr shared. You must also authenticate with a same-organization API key or bot key that has chats.read. A request made with only the returned URL receives 401 auth_required; moving its query token into Authorization: Bearer receives 401 invalid_token because Bearer authentication accepts Helpr user-session access tokens, not webhook capability tokens.
Create the API key pair under Settings → Developer → API keys. Send the publishable key in X-Helpr-Key; keep its matching HMAC secret on your server and never send the secret itself. Then follow the complete href exactly as returned. The API request signature is:
X-Helpr-Key: <hlpr_pk_… or hlpr_bk_…>
X-Helpr-Timestamp: <current Unix seconds>
X-Helpr-Signature: sha256=<lowercase HMAC-SHA256 hex>
canonical = UPPERCASE_METHOD + "\n"
+ EXACT_PATH_AND_QUERY + "\n"
+ TIMESTAMP + "\n"
+ SHA256_HEX(RAW_BODY_BYTES)
signature = "sha256=" + HMAC_SHA256_HEX(API_KEY_SECRET, canonical)
Use the exact encoded path and query string from the href, beginning with /api/v1/…. Do not include the scheme or hostname, and do not decode, edit, sort, or reconstruct query parameters. For a GET, hash the empty body. There is no trailing newline after the body hash. Helpr rejects timestamps more than 5 minutes in the past or future and compares the signature in constant time.
This example fetches api_resources.message. It uses the API-key secret, not the webhook signing secret, and uses printf so the canonical value contains real newline bytes:
PK='hlpr_pk_your_publishable_key'
SK='hlpr_sk_your_hmac_secret'
HREF='https://helpr.so/api/v1/chats/message?id=18924&token=copy-the-returned-token'
TARGET="${HREF#https://helpr.so}"
TS="$(date +%s)"
BODY_SHA256="$(printf '' | sha256sum | awk '{print $1}')"
DIGEST="$(printf 'GET\n%s\n%s\n%s' "$TARGET" "$TS" "$BODY_SHA256" \
| openssl dgst -sha256 -hmac "$SK" -hex | awk '{print $NF}')"
curl --request GET "$HREF" \
--header "X-Helpr-Key: $PK" \
--header "X-Helpr-Timestamp: $TS" \
--header "X-Helpr-Signature: sha256=$DIGEST"
The capability normally expires 15 minutes after serialization. Initial delivery, automatic retry, manual replay, and Events API catch-up each issue fresh links, so use the href in the response currently being processed. A retained legacy payload or a deployment without resource-token signing may contain an untokenized URL; signed API-key authentication and ordinary inbox authorization are still required. Do not cache, log, edit, or construct tokenized URLs. An expired or invalid capability falls back to the key’s ordinary inbox access; if that access is also denied, Helpr returns 404.
| Request | Response | Meaning |
|---|---|---|
| Returned href only | 401 auth_required | The capability URL still needs signed API-key authentication. |
| Capability token used as Bearer | 401 invalid_token | Bearer accepts a Helpr user access token, not the URL capability. |
Signed key without chats.read | 403 insufficient_scope | Add chats.read to the API or bot key. |
| Invalid/expired capability and no ordinary inbox access | 404 | Fetch a fresh href from the current delivery or catch-up response. |
Do not mix the signature protocols
API request signing and webhook delivery verification travel in opposite directions and use separate, versioned canonical formats:
| Direction | Required signature | Canonical input |
|---|---|---|
| Your integration → Helpr API | X-Helpr-Key, X-Helpr-Timestamp, and X-Helpr-Signature: sha256=… | METHOD\npath?query\ntimestamp\nsha256(body) |
| Helpr → your event webhook | X-Helpr-Signature-V1: t=…,v1=… | timestamp + "." + raw_body |
| Helpr → your Data API callback | X-Helpr-Timestamp and X-Helpr-Signature: sha256=… | timestamp + "." + raw_body. This is not Platform API request signing. |
| Helpr → legacy configured-webhook receiver | X-Helpr-Signature: sha256=… | Raw body only. Migration compatibility; do not use for a new receiver. |
The identical legacy/API header name does not imply an identical canonical string. Choose the row by request direction and require the documented format. Never use a webhook verifier to call the API, and never use API canonicalization to verify an event webhook.
Wildcard Subscription
Pass ["*"] as the events array to receive every event type, including any added in the future.
{
"url": "https://your-server.com/helpr/webhook",
"events": ["*"]
}
Automation webhook actions
Beyond event subscriptions, an automation can queue a webhook as one of its actions — so the request is created only when the automation's trigger and conditions match (for example, an email from a specific sender, on a specific inbox, during business hours).
- Create an HTTPS webhook endpoint under Settings → Developer → Webhooks and store its signing secret.
- Add a Trigger webhook action under Settings → Automations.
- Select the configured endpoint. New automation actions do not accept an arbitrary URL or a separate signing secret.
An endpoint selected by an automation is automation-only: it cannot also have direct event subscriptions. This prevents the conditional automation action and an unconditional subscription from delivering the same event to one endpoint.
The automation endpoint must be active when the action runs. Unlike a paused direct subscription, a paused or disabled automation-only endpoint does not accept a newly targeted automation delivery.
Automation deliveries use the same durable delivery queue, endpoint signing secret, delivery log, replay tools, and retry schedule as event-subscription deliveries. Verify the canonical v1 header exactly as shown under Signature Verification.
X-Helpr-Event and the body’s event field identify the automation’s trigger event. Configured-endpoint deliveries use the same canonical events-v1 envelope as subscription events. For an email trigger, data is the same rich email payload documented under Email events for the exact message that caused the automation to run, with an additional automation identity object. The combined inbound trigger also includes inbound_conversation_type as new or existing.
POST https://your-api.com/webhook
X-Helpr-Event: email.inbound
X-Helpr-Delivery: 98231
X-Helpr-Signature: sha256=<legacy_hmac_hex_digest>
X-Helpr-Signature-V1: t=1717521605,v1=<hmac_hex_digest>
X-Helpr-Timestamp: 1717521605
X-Helpr-Webhook-Version: 2026-07-13-events-v1
{
"id": "evt_18454",
"event": "email.inbound",
"type": "email.inbound",
"schema_version": "2026-07-13-events-v1",
"timestamp": "2026-07-13T18:20:05.000000Z",
"sequence": "18454",
"data": {
"event_id": "email.inbound:18924",
"chat_id": 109823,
"team_id": 3,
"organization_id": 1,
"message_id": 18924,
"direction": "inbound",
"provider": "gmail",
"automation": { "id": 42, "name": "VIP email alert" },
"inbound_conversation_type": "existing",
"email": { "subject": "Urgent: order #5521", "body_included": false, "body_size_bytes": 312 },
"api_resources": {
"message": {
"href": "https://helpr.so/api/v1/chats/message?id=18924&token=eyJ2Ijox...abc",
"method": "GET",
"scope": "chats.read"
}
}
},
"context": {
"organization_id": 1,
"team_id": 3,
"secondary_team_id": null,
"inbox_id": 12,
"chat_id": "109823",
"message_id": "18924",
"visibility": "team"
}
}
Legacy direct URLs: automations saved before configured endpoint selection was introduced continue to run through a compatibility path. They remain HTTPS-only and SSRF-protected, but do not gain the configured endpoint’s durable retries, delivery log, versioned envelope, or shared signing-secret lifecycle until you migrate them in the editor. When signing is enabled, verify their X-Helpr-Signature-V1 header; for historical compatibility only, their unsuffixed X-Helpr-Signature also contains the t=…,v1=… value rather than the configured-webhook body-only legacy format. Email-triggered legacy actions receive the July 13 bodyless email data shape without a schema marker, another reason to migrate them before relying on version negotiation.
Request Format
Every webhook delivery is an HTTP POST with a JSON body and these headers:
The canonical envelope contains a stable id; equivalent event and type fields; schema_version; immutable event timestamp; organization sequence; event-specific data; and routing context. Context IDs can be null; chat and message IDs in context are strings so JavaScript does not lose integer precision. A manual replay keeps the same event ID and timestamp but receives a new X-Helpr-Delivery.
Schema versions are additive within a version: tolerate unknown object fields, preserve numeric-string IDs, and ignore event types you do not subscribe to or recognize. Do not reject a payload solely because a new optional field appears. A deliberately incompatible shape will use a new schema_version.
| Header | Example | Description |
|---|---|---|
Content-Type | application/json | Always JSON |
X-Helpr-Signature-V1 | t=1717521600,v1=a1b2c3d4… | Required for new integrations. HMAC-SHA256 of the raw bytes "<timestamp>.<body>" |
X-Helpr-Timestamp | 1717521600 | Unix timestamp used by the v1 signature |
X-Helpr-Signature-Previous-V1 | t=…,v1=… | Optional v1 signature with the previous secret during a 7-day rotation grace period |
X-Helpr-Signature | sha256=a1b2c3d4… | Body-only legacy signature, emitted for migration compatibility; new code must not use it as a fallback. |
X-Helpr-Signature-Previous | sha256=… | Previous-secret legacy signature during rotation; migration-only. |
X-Helpr-Event | chat.message | The event type |
X-Helpr-Delivery | 98231 | Delivery-row ID for tracing. Automatic retries reuse it; manual replay creates a new one. Deduplicate by event id. |
X-Helpr-Webhook-Version | 2026-07-13-events-v1 | Matches the body’s schema_version. Retained legacy/replay rows may use 2026-05-14-compact. |
Signature Verification
Verify every request before parsing or processing it. New integrations must require X-Helpr-Signature-V1. Its format is t=<unix-seconds>,v1=<64 lowercase hex characters>, where:
v1 = HMAC-SHA256(secret, ASCII(timestamp) + "." + raw_request_body_bytes)
Read the body as raw bytes, reject timestamps more than 5 minutes in the past or future, and compare the received and calculated digests in constant time. JSON parsing, re-encoding, whitespace changes, and character-set conversion all change the signed bytes. In Express, register the webhook’s express.raw() route before a global express.json() middleware; in Flask use request.get_data(); in PHP read php://input.
During rotation, accept either the primary v1 header with the current secret or X-Helpr-Signature-Previous-V1 with the separately stored previous secret. Never try a previous secret against the primary header, and never fall back from a missing or invalid v1 header to X-Helpr-Signature. The body-only legacy headers exist only so an existing receiver can migrate.
This command generates a valid local test request. It signs exactly the bytes passed to curl:
SECRET='replace-with-your-endpoint-secret'
BODY='{"event":"webhook.test","schema_version":"2026-05-14-compact","data":{"test":true}}'
TIMESTAMP="$(date +%s)"
DIGEST="$(printf '%s' "${TIMESTAMP}.${BODY}" | openssl dgst -sha256 -hmac "$SECRET" -hex | awk '{print $NF}')"
curl --request POST 'https://your-server.example/helpr/webhook' \
--header 'Content-Type: application/json' \
--header "X-Helpr-Signature-V1: t=${TIMESTAMP},v1=${DIGEST}" \
--header "X-Helpr-Timestamp: ${TIMESTAMP}" \
--header 'X-Helpr-Event: webhook.test' \
--header 'X-Helpr-Delivery: local-test-1' \
--data-binary "$BODY"
The examples below use an in-memory set to make duplicate handling visible. In production, atomically insert event.id into a durable inbox table with a unique constraint and enqueue your work in the same transaction. Return 2xx for an already-claimed event.
const crypto = require('node:crypto');
const express = require('express');
const app = express();
const claimed = new Set(); // Replace with a durable UNIQUE(event_id) inbox.
function verifyV1(rawBody, header, secret, toleranceSeconds = 300) {
if (!Buffer.isBuffer(rawBody) || !secret || typeof header !== 'string') return false;
const fields = new Map();
for (const item of header.split(',')) {
const match = item.trim().match(/^([a-z0-9_]+)=(.+)$/);
if (!match || fields.has(match[1])) return false;
fields.set(match[1], match[2]);
}
const timestamp = fields.get('t') || '';
const signature = fields.get('v1') || '';
if (!/^\d{9,12}$/.test(timestamp) || !/^[0-9a-f]{64}$/.test(signature)) return false;
if (Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp)) > toleranceSeconds) return false;
const expected = crypto.createHmac('sha256', secret)
.update(Buffer.from(`${timestamp}.`, 'ascii')).update(rawBody).digest();
const received = Buffer.from(signature, 'hex');
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
// Register this route before any app.use(express.json()).
app.post('/helpr/webhook', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
const currentOK = verifyV1(req.body, req.get('X-Helpr-Signature-V1'),
process.env.HELPR_WEBHOOK_SECRET);
const previousSecret = process.env.HELPR_WEBHOOK_PREVIOUS_SECRET || '';
const previousOK = previousSecret !== '' && verifyV1(req.body,
req.get('X-Helpr-Signature-Previous-V1'), previousSecret);
if (!currentOK && !previousOK) return res.status(401).send('Invalid signature');
let payload;
try { payload = JSON.parse(req.body.toString('utf8')); }
catch { return res.status(400).send('Invalid JSON'); }
if (payload.event === 'webhook.test') return res.status(200).send('verified');
if (typeof payload.id !== 'string' || payload.id.length > 128) return res.status(400).send('Invalid event id');
if (claimed.has(payload.id)) return res.status(200).send('duplicate');
claimed.add(payload.id); // Atomically persist + enqueue here in production.
setImmediate(() => console.log('process', payload.event, payload.id));
return res.status(202).send('accepted');
});
app.listen(3000);
import hashlib, hmac, json, os, re, threading, time
from flask import Flask, request
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 1024 * 1024
claimed = set() # Replace with a durable UNIQUE(event_id) inbox.
claimed_lock = threading.Lock()
def verify_v1(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
if not secret or not header:
return False
fields = {}
for item in header.split(','):
pair = item.strip().split('=', 1)
if len(pair) != 2 or pair[0] in fields:
return False
fields[pair[0]] = pair[1]
timestamp, signature = fields.get('t', ''), fields.get('v1', '')
if not re.fullmatch(r'\d{9,12}', timestamp) or not re.fullmatch(r'[0-9a-f]{64}', signature):
return False
if abs(int(time.time()) - int(timestamp)) > tolerance:
return False
expected = hmac.new(secret.encode(), timestamp.encode('ascii') + b'.' + raw_body,
hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, expected)
@app.post('/helpr/webhook')
def helpr_webhook():
raw_body = request.get_data(cache=False, as_text=False) # Before JSON parsing.
current_ok = verify_v1(raw_body, request.headers.get('X-Helpr-Signature-V1', ''),
os.environ['HELPR_WEBHOOK_SECRET'])
previous_secret = os.getenv('HELPR_WEBHOOK_PREVIOUS_SECRET', '')
previous_ok = bool(previous_secret) and verify_v1(
raw_body, request.headers.get('X-Helpr-Signature-Previous-V1', ''), previous_secret)
if not current_ok and not previous_ok:
return 'Invalid signature', 401
try:
payload = json.loads(raw_body)
except (UnicodeDecodeError, json.JSONDecodeError):
return 'Invalid JSON', 400
if payload.get('event') == 'webhook.test':
return 'verified', 200
if not isinstance(payload.get('id'), str) or len(payload['id']) > 128:
return 'Invalid event id', 400
with claimed_lock:
if payload['id'] in claimed:
return 'duplicate', 200
claimed.add(payload['id']) # Atomically persist + enqueue here in production.
print('process', payload.get('event'), payload['id'])
return 'accepted', 202
<?php
declare(strict_types=1);
function verify_v1(string $rawBody, string $header, string $secret, int $tolerance = 300): bool {
if ($secret === '' || $header === '') return false;
$fields = [];
foreach (explode(',', $header) as $item) {
$pair = explode('=', trim($item), 2);
if (count($pair) !== 2 || isset($fields[$pair[0]])) return false;
$fields[$pair[0]] = $pair[1];
}
$timestamp = $fields['t'] ?? '';
$signature = $fields['v1'] ?? '';
if (!preg_match('/^\d{9,12}$/D', $timestamp) || !preg_match('/^[0-9a-f]{64}$/D', $signature)) return false;
if (abs(time() - (int) $timestamp) > $tolerance) return false;
$expected = hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
return hash_equals($expected, $signature);
}
$raw = file_get_contents('php://input'); // Read once, before json_decode().
if (!is_string($raw)) { http_response_code(400); exit('Missing body'); }
if (strlen($raw) > 1024 * 1024) { http_response_code(413); exit('Payload too large'); }
$currentOK = verify_v1(
$raw,
$_SERVER['HTTP_X_HELPR_SIGNATURE_V1'] ?? '',
(string) getenv('HELPR_WEBHOOK_SECRET')
);
$previousSecret = (string) getenv('HELPR_WEBHOOK_PREVIOUS_SECRET');
$previousOK = $previousSecret !== '' && verify_v1(
$raw,
$_SERVER['HTTP_X_HELPR_SIGNATURE_PREVIOUS_V1'] ?? '',
$previousSecret
);
if (!$currentOK && !$previousOK) {
http_response_code(401);
exit('Invalid signature');
}
try { $payload = json_decode($raw, true, 512, JSON_THROW_ON_ERROR); }
catch (JsonException) { http_response_code(400); exit('Invalid JSON'); }
if (($payload['event'] ?? '') === 'webhook.test') exit('verified');
if (!is_string($payload['id'] ?? null) || strlen($payload['id']) > 128) {
http_response_code(400); exit('Invalid event id');
}
// Durable demo inbox. Point this outside the web root; a worker processes "pending" rows.
$inboxPath = (string) getenv('HELPR_WEBHOOK_INBOX_PATH');
if ($inboxPath === '') { http_response_code(500); exit('Inbox storage is not configured'); }
$db = new PDO('sqlite:' . $inboxPath);
$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
$db->exec('CREATE TABLE IF NOT EXISTS webhook_inbox (
event_id TEXT PRIMARY KEY, payload TEXT NOT NULL, status TEXT NOT NULL, received_at TEXT NOT NULL
)');
$insert = $db->prepare(
'INSERT OR IGNORE INTO webhook_inbox(event_id, payload, status, received_at) VALUES(?, ?, ?, ?)'
);
$insert->execute([$payload['id'], $raw, 'pending', gmdate('c')]);
if ($insert->rowCount() === 0) exit('duplicate');
http_response_code(202);
echo 'accepted';
Retries & Failures
Helpr queues each delivery immediately and attempts it asynchronously. Your endpoint must complete its connection within 5 seconds and return any 2xx response within 10 seconds total. The response body is not interpreted. Redirects are never followed, so a 3xx is a failure. If a retryable request fails, Helpr uses this backoff:
| Attempt | Retry after |
|---|---|
| 1st failure | 1 minute |
| 2nd failure | 5 minutes |
| 3rd failure | 15 minutes |
| 4th failure | 1 hour |
| 5th failure | 3 hours |
| 6th failure | 6 hours |
| 7th failure | 12 hours |
| 8th failure | Marked as failed |
For 429 Too Many Requests and 503 Service Unavailable, Helpr honors a valid Retry-After response header in either delta-seconds or HTTP-date form. The requested delay is clamped between 1 second and 24 hours. Helpr waits for whichever is later — local backoff or receiver request — and applies that cooldown to the endpoint’s other pending work.
Network errors, timeouts, 408, 425, 429, and 5xx responses are retryable. Other 4xx responses are terminal and are not retried. A 429 response does not count toward automatic endpoint disablement.
After 100 consecutive receiver failures across deliveries, the webhook is automatically disabled. A successful delivery resets the failure counter. Auto-disable retains authorized pending deliveries and starts no new attempt until reactivation; an attempt already in flight is allowed to record its result and a success reactivates the endpoint.
Diagnostics and replay: the delivery log records the event and delivery IDs, status, attempt count, timing, response code, a bounded response preview, the receiver’s X-Request-Id/Request-Id/X-Correlation-Id when supplied, and the next retry time. Logs and encrypted journal events are retained for 90 days. A normal manual replay is allowed only while the endpoint is active and the delivery’s authorization version is still current. It creates a new delivery ID, preserves the original event ID and event data, and generates fresh capability links, timestamp, and signatures when sent.
If the endpoint URL, scope, or event selection changed, the delivery log instead shows Review and reauthorize replay when a retained journal event is available. This is a separate, confirmed action: Helpr decrypts and rebuilds the immutable event, verifies its organization, current event selection, and current team/organization scope, then queues it under the endpoint’s current authorization version. It never reuses the old plaintext delivery body. Legacy rows without a journal event, expired events, and events outside the current authorization remain unavailable.
Events catch-up API
Use GET /api/v1/events (or the /api/v1/events/list alias) to reconcile journaled integration events after downtime. It reads the same encrypted journal as webhooks and requires an API key or bot key with events.read; user bearer tokens are not accepted. Sign the complete request URI, including its query string, with the normal API HMAC authentication.
GET /api/v1/events?sort=asc&limit=100&types=email.received,email.sent
{
"ok": true,
"data": {
"events": [{
"id": "evt_18453",
"event": "email.received",
"type": "email.received",
"schema_version": "2026-07-13-events-v1",
"sequence": "18453",
"timestamp": "2026-07-13T14:32:10.345678Z",
"data": {
"conversation_id": 4521,
"message_id": 18924,
"api_resources": {
"message": {
"href": "https://helpr.so/api/v1/chats/message?id=18924&token=eyJ2Ijox...fresh"
}
}
},
"context": {
"organization_id": 1,
"team_id": 3,
"secondary_team_id": null,
"inbox_id": 12,
"chat_id": "4521",
"message_id": "18924",
"visibility": "team"
}
}],
"pagination": {
"has_more": false,
"next_cursor": "eyJ2ZXJzaW9uIjoxLC4uLg.signature",
"snapshot_sequence": "18453"
}
}
}
Pass next_cursor back unchanged. During pagination it freezes a sequence watermark, so new events cannot move page boundaries. After the frozen set is exhausted, the returned cursor is a polling checkpoint; send it again to open the next frozen window. With cursor, only limit may also be supplied because the signed cursor carries the original filters.
Initial requests support types or event_types, team_ids, inbox_ids, conversation_id, emitted_after, emitted_before, sort=asc|desc, and limit (1–100). Events are retained for 90 days. If a checkpoint or frozen page can no longer prove a gap-free result, Helpr returns 410 cursor_expired; begin a new catch-up and rely on event-ID deduplication.
Team-scoped keys see only events authorized for that team. Organization-scoped keys may query non-personal events across the organization and narrow them with team/inbox filters. Personal-inbox events are intentionally excluded from catch-up, even though an organization-scoped webhook can receive them in real time.
Every catch-up envelope is serialized with fresh 15-minute api_resources capability links. Follow those returned hrefs immediately with a same-organization chats.read API key; do not reuse an older webhook link.
Best Practices
- Return 2xx quickly. Process events asynchronously — queue the payload, respond immediately. If your handler takes more than 10 seconds, the delivery is marked as failed.
- Deduplicate with the event ID. The same event may have multiple delivery attempts or be manually replayed. Store the stable top-level
id; useX-Helpr-Deliveryonly to trace a particular delivery. - Reconcile with the Events API. Persist the returned checkpoint cursor and poll it after outages instead of assuming realtime delivery is exhaustive.
- Fetch full records on demand. Treat webhook payloads as notifications. Use
api_resourceswhen you need full message HTML, attachment downloads, raw EML, or the latest conversation state. - Always verify signatures. Require
X-Helpr-Signature-V1, sign the raw body bytes, enforce the 5-minute timestamp window, and use constant-time comparison. - Use HTTPS. Webhook payloads contain conversation data. Only HTTPS endpoints are accepted.
- Monitor your endpoint. If your endpoint starts failing, Helpr disables the webhook after 100 consecutive failures. Set up alerting on your side.