SendMeDocs / Docs AI Assistants External API Webhooks MCP Server Zapier & Automations

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

  1. Go to Settings > Webhooks in the dashboard (owner/admin only)
  2. Click Add Endpoint, enter your HTTPS URL, select events
  3. Copy the secret — it's shown only once
  4. Your endpoint receives POST requests with a JSON body and HMAC-SHA256 signature
  5. 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

After implementation

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

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": "[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"
  }
}

`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": "[email protected]",
    "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": "[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 }
    ]
  }
}

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

  1. Verify signatures — Always validate the HMAC signature before processing. Use constant-time comparison.
  2. Respond quickly — Return a 2xx status within a few seconds. Process the event asynchronously if needed.
  3. Use the event ID for idempotency — The id field in the envelope is unique per event. Store it to avoid processing duplicates.
  4. Monitor your delivery log — Check the webhook settings page in the dashboard for failed deliveries.
  5. Handle retries gracefully — Your endpoint may receive the same event multiple times if earlier attempts failed or timed out.

Limits

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"
}

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

  1. Set up an HTTPS endpoint that accepts POST with Content-Type: application/json
  2. Read headers: Webhook-Signature (for verification), Webhook-Id (for idempotency), Webhook-Timestamp (for replay protection)
  3. Verify signature: HMAC-SHA256 of "{timestamp}.{raw_body}" using your secret, compare with v1 value from header
  4. Reject old timestamps: if abs(now - timestamp) > 300 seconds, reject
  5. Parse the JSON body as the event envelope: { id, type, created_at, data }
  6. Switch on type: request.created, request.completed, request.expired, request.revision_requested
  7. Store the event id to deduplicate retries
  8. Return 200 OK as 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