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.
POST requests with a JSON body and HMAC-SHA256 signature2xx, process the eventComplete 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.
ngrok http 3000) to get a temporary HTTPS URL.* (all events) and filter in your code..env file, or wherever your receiver will read it from. If you lose it, delete the endpoint and create a new one.SENDMEDOCS_WEBHOOK_SECRET) so your code can verify signatures.request.created payload.request.created event.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.
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 |
| 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 |
# 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.
| 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 | 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.
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) |
{
"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": "[email protected]",
"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"
}
}
{
"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"
}
}
{
"id": "c3d4e5f6-...",
"type": "request.expired",
"created_at": "2026-02-17T00:00:00.000Z",
"data": {
"id": "019471a2-...",
"recipient_name": "Jane Doe",
"recipient_contact": "[email protected]",
"recipient_contact_type": "email",
"status": "expired"
}
}
{
"id": "d4e5f6a7-...",
"type": "request.revision_requested",
"created_at": "2026-02-11T09:00:00.000Z",
"data": {
"id": "019471a2-...",
"recipient_name": "Jane Doe",
"recipient_contact": "[email protected]",
"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 }
]
}
}
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 |
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.
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);
}
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)
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
}
| 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.
id field in the envelope is unique per event. Store it to avoid processing duplicates.All endpoints require authentication and owner/admin role. Base path: /api/orgs/:orgSlug/webhooks.
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
| 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. |
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 with https://events (required) — array of event type strings, or ["*"] for all eventsdescription (optional) — human-readable label, max 255 charsResponse (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...).
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"
}
}
{
"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 }
}
]
}
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 |
This section is optimized for AI-assisted development (LLM agents, copilots, code generators).
POST with Content-Type: application/jsonWebhook-Signature (for verification), Webhook-Id (for idempotency), Webhook-Timestamp (for replay protection)"{timestamp}.{raw_body}" using your secret, compare with v1 value from headerabs(now - timestamp) > 300 seconds, reject{ id, type, created_at, data }type: request.created, request.completed, request.expired, request.revision_requestedid to deduplicate retries200 OK as fast as possible, then process asynchronously| 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 |
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 }[]
}
split('=') for parsing the signature header — use split('=', 1) or indexOf('=') to handle values that might contain =JSON.stringify(parsedBody) — verify against the raw request body bytes. Re-serialization may change key ordering.