Webhooks
SendMeDocs can push real-time event notifications to your HTTPS endpoint whenever something happens with a document request. This lets you build integrations without polling the API.
Quick start
- Go to Settings > Webhooks in the dashboard (owner/admin only)
- Click Add Endpoint, enter your HTTPS URL, select events
- Copy the secret — it's shown only once
- Your endpoint receives
POSTrequests with a JSON body and HMAC-SHA256 signature - Verify the signature, return
2xx, process the event
Human operator checklist
Complete these steps before an AI (or developer) can implement the receiver. These require dashboard access, infrastructure decisions, or secrets that can't be generated by code alone.
Before implementation
- Deploy a publicly-reachable HTTPS endpoint — the URL where SendMeDocs will POST events. For local testing, use a tunnel like ngrok (
ngrok http 3000) to get a temporary HTTPS URL. - Decide which events you need — see Event types below. If unsure, start with
*(all events) and filter in your code. - Create the webhook endpoint in SendMeDocs — go to Settings > Webhooks in the dashboard (requires owner or admin role). Enter your URL, select events, click Create.
- Copy the secret immediately — it is shown exactly once. Store it in your secrets manager,
.envfile, or wherever your receiver will read it from. If you lose it, delete the endpoint and create a new one. - Provide the secret to your receiver — set it as an environment variable (e.g.
SENDMEDOCS_WEBHOOK_SECRET) so your code can verify signatures.
After implementation
- Send a test event — click the Test button next to your endpoint in the dashboard. This sends a sample
request.createdpayload. - Check the delivery log — expand the endpoint in the dashboard to see if the test delivery shows success (green) or failed (red).
- Trigger a real event — create a document request in SendMeDocs and verify your receiver processes the
request.createdevent.
Testing & debugging
Using the Test button
The dashboard's Test button (next to each webhook endpoint) sends a synthetic request.created event with placeholder data. It uses your real secret and signature, so it's a valid end-to-end test of your verification logic.
Reading the delivery log
Expand any endpoint in Settings > Webhooks to see its delivery history:
| Column | What it means |
|---|---|
| Status (green/red/yellow) | success = got 2xx, failed = all retries exhausted, pending = still retrying |
| Event type | Which event was delivered |
| Attempts | How many delivery attempts were made (max 4) |
| HTTP status | The response status code your endpoint returned (e.g. 200, 500, null if connection failed) |
| Error | Network error message or HTTP 500 etc. — tells you why it failed |
| Time | When the delivery was created |
Common failure scenarios
| Symptom | Likely cause | Fix |
|---|---|---|
Status: failed, Error: HTTP 401 |
Your endpoint requires auth that SendMeDocs doesn't provide | Make your webhook receiver endpoint public (no auth) or whitelist the User-Agent: SendMeDocs-Webhooks/1.0 |
Status: failed, Error: fetch failed or ECONNREFUSED |
Your endpoint isn't reachable from the internet | Check firewall rules, confirm HTTPS URL is publicly accessible |
Status: failed, Error: HTTP 500 |
Your receiver is crashing | Check your server logs. Common: JSON parse error, missing env var for secret |
Status: failed, Error: The operation was aborted |
Your endpoint took longer than 10 seconds to respond | Return 200 immediately, process the event asynchronously |
Status: success but nothing happens in your system |
Signature verification is passing but event processing is silently failing | Add logging to your event handler. Check you're switching on the right type values |
| Duplicate processing | Retries delivered the same event multiple times | Store the id from the event envelope and skip events you've already processed |
Local development with ngrok
# 1. Start your local receiver on port 3000
node server.js
# 2. Expose it via ngrok
ngrok http 3000
# 3. Copy the https://xxxx.ngrok.io URL
# 4. Create a webhook endpoint in SendMeDocs with that URL
# 5. Click Test — check your local console for the incoming request
When ngrok restarts, the URL changes. You'll need to update the endpoint URL in the dashboard.
Overview
- Signing: HMAC-SHA256 with a per-endpoint secret
- Transport: HTTPS POST with JSON body
- Retries: 4 total attempts (initial + 3 retries) with exponential backoff
- Delivery log: 30-day retention, viewable in the dashboard
What you need
| Input | Where to get it | Notes |
|---|---|---|
| HTTPS endpoint URL | Your server | Must be https://. No http://. |
| Webhook secret | Shown once when you create the endpoint in the dashboard | 64-char hex string. Store securely. |
| Event subscriptions | Selected at endpoint creation (editable later) | * for all, or pick specific event types |
Event types
| Event | Fired when |
|---|---|
request.created |
A new document request is created |
request.completed |
A recipient submits all required documents |
request.expired |
A pending request passes its expiry date |
request.revision_requested |
A reviewer sends a request back for revisions |
You can subscribe to individual events or use * to receive all events.
Event envelope
Every webhook delivery sends a JSON object with this structure:
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "request.created",
"created_at": "2026-02-10T12:00:00.000Z",
"data": { ... }
}
| Field | Type | Description |
|---|---|---|
id |
string (UUID) | Unique event ID. Use for idempotency. |
type |
string | One of the event types above |
created_at |
string (ISO 8601) | When the event was generated |
data |
object | Event-specific payload (see below) |
Payload examples
`request.created`
{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"type": "request.created",
"created_at": "2026-02-10T12:00:00.000Z",
"data": {
"id": "019471a2-...",
"token": "abc123...",
"recipient_name": "Jane Doe",
"recipient_contact": "jane@example.com",
"recipient_contact_type": "email",
"status": "pending",
"items": [
{ "name": "Photo ID", "status": "pending" },
{ "name": "Proof of Address", "status": "pending" }
],
"upload_url": "/u/abc123...",
"created_at": "2026-02-10T12:00:00.000Z"
}
}
`request.completed`
{
"id": "b2c3d4e5-...",
"type": "request.completed",
"created_at": "2026-02-10T14:30:00.000Z",
"data": {
"id": "019471a2-...",
"recipient_name": "Jane Doe",
"status": "completed",
"items": [
{ "name": "Photo ID", "status": "submitted", "file_count": 1 },
{ "name": "Proof of Address", "status": "submitted", "file_count": 1 }
],
"completed_at": "2026-02-10T14:30:00.000Z"
}
}
`request.expired`
{
"id": "c3d4e5f6-...",
"type": "request.expired",
"created_at": "2026-02-17T00:00:00.000Z",
"data": {
"id": "019471a2-...",
"recipient_name": "Jane Doe",
"recipient_contact": "jane@example.com",
"recipient_contact_type": "email",
"status": "expired"
}
}
`request.revision_requested`
{
"id": "d4e5f6a7-...",
"type": "request.revision_requested",
"created_at": "2026-02-11T09:00:00.000Z",
"data": {
"id": "019471a2-...",
"recipient_name": "Jane Doe",
"recipient_contact": "jane@example.com",
"recipient_contact_type": "email",
"revision_count": 1,
"message": "Please re-upload a clearer photo.",
"items": [
{ "item_id": "...", "name": "Photo ID", "status": "rejected", "note": "Image is blurry" },
{ "item_id": "...", "name": "Proof of Address", "status": "approved", "note": null }
]
}
}
Headers
Every webhook request includes these headers:
| Header | Example | Description |
|---|---|---|
Content-Type |
application/json |
Always JSON |
Webhook-Id |
a1b2c3d4-... |
Same as envelope id |
Webhook-Timestamp |
1707566400 |
Unix timestamp (seconds) |
Webhook-Signature |
t=1707566400,v1=abc123... |
Signature for verification |
User-Agent |
SendMeDocs-Webhooks/1.0 |
Identifies the sender |
Signature verification
The signature header has the format t={unix_seconds},v1={hmac_hex}.
The signed payload is {timestamp}.{json_body} — the timestamp from the header, a literal dot, then the raw JSON request body.
Node.js
import crypto from 'crypto';
function verifyWebhook(body, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(',').map(p => {
const i = p.indexOf('=');
return [p.slice(0, i), p.slice(i + 1)];
})
);
const timestamp = parts.t;
const receivedSig = parts.v1;
// Reject timestamps older than 5 minutes (replay protection)
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) {
throw new Error('Timestamp too old');
}
const expectedSig = crypto
.createHmac('sha256', secret)
.update(`${timestamp}.${body}`)
.digest('hex');
if (!crypto.timingSafeEqual(
Buffer.from(expectedSig, 'hex'),
Buffer.from(receivedSig, 'hex')
)) {
throw new Error('Invalid signature');
}
return JSON.parse(body);
}
Python
import hmac, hashlib, time, json
def verify_webhook(body: bytes, signature_header: str, secret: str):
parts = dict(p.split("=", 1) for p in signature_header.split(","))
timestamp = parts["t"]
received_sig = parts["v1"]
# Reject timestamps older than 5 minutes
if abs(time.time() - int(timestamp)) > 300:
raise ValueError("Timestamp too old")
expected_sig = hmac.new(
secret.encode(), f"{timestamp}.{body.decode()}".encode(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(expected_sig, received_sig):
raise ValueError("Invalid signature")
return json.loads(body)
Go
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"math"
"strconv"
"strings"
"time"
)
func VerifyWebhook(body []byte, signatureHeader, secret string) error {
parts := make(map[string]string)
for _, p := range strings.Split(signatureHeader, ",") {
kv := strings.SplitN(p, "=", 2)
if len(kv) == 2 {
parts[kv[0]] = kv[1]
}
}
timestamp, _ := strconv.ParseInt(parts["t"], 10, 64)
if math.Abs(float64(time.Now().Unix()-timestamp)) > 300 {
return fmt.Errorf("timestamp too old")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(fmt.Sprintf("%d.%s", timestamp, body)))
expectedSig := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expectedSig), []byte(parts["v1"])) {
return fmt.Errorf("invalid signature")
}
return nil
}
Retry behavior
| Attempt | Delay | Total elapsed |
|---|---|---|
| 1 (initial) | immediate | 0s |
| 2 | 5 seconds | ~5s |
| 3 | 30 seconds | ~35s |
| 4 | 5 minutes | ~5m 35s |
A delivery is marked success on any 2xx response. After all 4 attempts fail, the delivery is marked failed.
Each delivery attempt has a 10-second timeout.
Best practices
- Verify signatures — Always validate the HMAC signature before processing. Use constant-time comparison.
- Respond quickly — Return a 2xx status within a few seconds. Process the event asynchronously if needed.
- Use the event ID for idempotency — The
idfield in the envelope is unique per event. Store it to avoid processing duplicates. - Monitor your delivery log — Check the webhook settings page in the dashboard for failed deliveries.
- Handle retries gracefully — Your endpoint may receive the same event multiple times if earlier attempts failed or timed out.
Limits
- Maximum 5 webhook endpoints per organization
- Endpoint URLs must use HTTPS
- Response bodies are stored (first 1 KB) for debugging
- Delivery logs are retained for 30 days
Management API
All endpoints require authentication and owner/admin role. Base path: /api/orgs/:orgSlug/webhooks.
Authentication
The webhook management endpoints below use cookie-based session auth (dashboard). For programmatic access to requests and files, use the External API with API key authentication — see `docs/api.md`.
# Example: list webhook endpoints (assuming session cookie is stored)
curl -b 'cookie.txt' https://your-instance.com/api/orgs/acme/webhooks
Endpoints
| Method | Path | Description |
|---|---|---|
POST / |
Create endpoint | Returns endpoint with plaintext secret (only time shown). |
GET / |
List endpoints | Secret masked. Includes 24h delivery stats per endpoint. |
GET /:id |
Get endpoint | Secret masked. Includes last 20 deliveries. |
PATCH /:id |
Update endpoint | Updatable: url, description, events, enabled. Secret is NOT updatable. |
DELETE /:id |
Delete endpoint | Cascades to deliveries. |
POST /:id/test |
Send test event | Dispatches a sample request.created. Returns { delivery_id }. |
GET /:id/deliveries |
List deliveries | Last 50. Optional ?status=pending|success|failed filter. |
Create endpoint — request/response
Request:
POST /api/orgs/acme/webhooks
Content-Type: application/json
{
"url": "https://api.example.com/webhooks/sendmedocs",
"events": ["request.completed", "request.expired"],
"description": "Production CRM integration"
}
url(required) — must start withhttps://events(required) — array of event type strings, or["*"]for all eventsdescription(optional) — human-readable label, max 255 chars
Response (201):
{
"endpoint": {
"id": "019471a2-b3c4-7d5e-8f6a-1234567890ab",
"url": "https://api.example.com/webhooks/sendmedocs",
"secret": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
"description": "Production CRM integration",
"events": ["request.completed", "request.expired"],
"enabled": true,
"created_at": "2026-02-10T12:00:00.000Z",
"updated_at": "2026-02-10T12:00:00.000Z"
}
}
This is the only time secret is returned in plaintext. All subsequent GET requests return a masked version (a1b2c3d4...).
Update endpoint — request/response
Request:
PATCH /api/orgs/acme/webhooks/019471a2-b3c4-7d5e-8f6a-1234567890ab
Content-Type: application/json
{
"events": ["*"],
"enabled": false
}
All fields are optional. Only include the fields you want to change.
Response (200):
{
"endpoint": {
"id": "019471a2-b3c4-7d5e-8f6a-1234567890ab",
"url": "https://api.example.com/webhooks/sendmedocs",
"secret": "a1b2c3d4...",
"description": "Production CRM integration",
"events": ["*"],
"enabled": false,
"created_at": "2026-02-10T12:00:00.000Z",
"updated_at": "2026-02-10T12:05:00.000Z"
}
}
List endpoints — response
{
"endpoints": [
{
"id": "019471a2-...",
"url": "https://api.example.com/webhooks/sendmedocs",
"secret": "a1b2c3d4...",
"description": "Production CRM integration",
"events": ["*"],
"enabled": true,
"created_at": "2026-02-10T12:00:00.000Z",
"updated_at": "2026-02-10T12:00:00.000Z",
"delivery_stats": { "success_24h": 42, "failed_24h": 1 }
}
]
}
Error responses
All errors return { "error": "message" } with an appropriate HTTP status:
| Status | Meaning |
|---|---|
| 400 | Validation failed (non-HTTPS URL, invalid events, limit reached) |
| 403 | Not owner/admin |
| 404 | Endpoint not found or doesn't belong to this org |
AI integration guide
This section is optimized for AI-assisted development (LLM agents, copilots, code generators).
Implementation checklist — webhook receiver
- Set up an HTTPS endpoint that accepts
POSTwithContent-Type: application/json - Read headers:
Webhook-Signature(for verification),Webhook-Id(for idempotency),Webhook-Timestamp(for replay protection) - Verify signature: HMAC-SHA256 of
"{timestamp}.{raw_body}"using your secret, compare withv1value from header - Reject old timestamps: if
abs(now - timestamp) > 300seconds, reject - Parse the JSON body as the event envelope:
{ id, type, created_at, data } - Switch on
type:request.created,request.completed,request.expired,request.revision_requested - Store the event
idto deduplicate retries - Return
200 OKas fast as possible, then process asynchronously
Inputs your receiver needs from the human/system
| Input | Source | Format | Example |
|---|---|---|---|
| Webhook secret | Dashboard (shown once at creation) | 64-char hex string | a1b2c3d4e5f6... |
| Expected event types | Configuration choice | String enum | request.completed |
| Endpoint URL | Deployed receiver | HTTPS URL | https://api.example.com/webhooks/sendmedocs |
Data shapes per event type
Use these TypeScript types if generating a receiver:
// Envelope — always this shape
interface WebhookEvent<T> {
id: string // UUID, unique per event — use for idempotency
type: string // "request.created" | "request.completed" | "request.expired" | "request.revision_requested"
created_at: string // ISO 8601
data: T
}
// request.created
interface RequestCreatedData {
id: string
token: string
recipient_name: string
recipient_contact: string
recipient_contact_type: "email" | "sms"
status: "pending"
items: { name: string; status: "pending" }[]
upload_url: string
created_at: string
}
// request.completed
interface RequestCompletedData {
id: string
recipient_name: string
status: "completed"
items: { name: string; status: string; file_count: number }[]
completed_at: string
}
// request.expired
interface RequestExpiredData {
id: string
recipient_name: string
recipient_contact: string
recipient_contact_type: "email" | "sms"
status: "expired"
}
// request.revision_requested
interface RequestRevisionData {
id: string
recipient_name: string
recipient_contact: string
recipient_contact_type: "email" | "sms"
revision_count: number
message: string | null
items: { item_id: string; name: string; status: "approved" | "rejected"; note: string | null }[]
}
Common mistakes to avoid
- Don't use
split('=')for parsing the signature header — usesplit('=', 1)orindexOf('=')to handle values that might contain= - Don't verify against
JSON.stringify(parsedBody)— verify against the raw request body bytes. Re-serialization may change key ordering. - Don't block on processing — return 2xx first, process async. The 10-second timeout is strict.
- Don't ignore the timestamp — without replay protection, an attacker who captures a payload can replay it indefinitely.