Everything you need to connect your product to Mocha Signature: how to authenticate, which environment to call, how to send your first envelope, and how to receive signed documents through webhooks.
What the Mocha Signature external API is for.
The Mocha Signature external API is the partner-facing surface of the Mocha Signature web app. Use it to read the templates in your account, send envelopes out for signing, track each recipient's progress, and receive the finished document on your own endpoint. Every operation is scoped to your tenant account — you only ever see your own data.
| You want to… | Use |
|---|---|
| List the templates available to your account | GET /get-template-by-user |
| Read a template's recipient roles before sending | GET /get-template-detail |
| Send a document for signature from your own app | POST /send-envelop |
| Show signing progress per recipient in your UI | GET /envelop-tracking |
| Be notified the moment a document is completed or declined | Custom webhooks (Settings → Custom Webhook) |
| Debug a webhook your endpoint did not accept | POST /webhook/deliveries |
Five steps from a new account to a signed document.
Sign up or log in to Mocha Signature
uat to the host: app.uat.mochatechnologies.com.Create a template
template_id — that is what you send to the API.Generate an API key
Call the endpoints
X-Tenant and Api-Key headers on every request. Start with /get-template-detail, then /send-envelop.Configure webhooks
Two headers, on every single request.
Every request must carry both headers. There is no OAuth flow, no token exchange and no expiry to manage on your side.
| Header | Value | Where to find it |
|---|---|---|
X-Tenant | Your tenant identifier | Provided with your account; the subdomain / organisation identifier your account belongs to |
Api-Key | The API key you generated | Settings → Key Management → + New Key |
Content-Type | application/json | Always — every endpoint takes a JSON body |
# Read your credentials from the environment - never paste an API key
# into a shell command, where it lands in your history and process list.
# export MOCHA_TENANT=...
# export MOCHA_API_KEY=...
curl -X GET \
'https://services.us.uat.mochatechnologies.com/signature/api/V1/get-template-by-user?user_id=acf28c4584f007dca67b' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "Api-Key: $MOCHA_API_KEY"Api-Key header is missing, the key was revoked, or the key does not belong to the tenant in X-Tenant. A 401 is also returned if the platform could not issue an internal token for the key's user — retry once, then check the key is still active in Settings.Test against UAT, then swap the host for production.
| Environment | API base URL | Web app |
|---|---|---|
| UAT (sandbox) | https://services.us.uat.mochatechnologies.com/signature/api/V1 | app.uat.mochatechnologies.com |
| Production | https://services.ap.mochatechnologies.com/signature/api/V1 | app.mochatechnologies.com |
Only the host differs — the /signature/api/V1 path and every endpoint below it are identical. Keep the base URL in configuration so you can promote an integration without touching code.
List the templates in your account, in four languages.
The quickest way to confirm your credentials work is to list your templates. A 200 with a data array means the tenant and key are valid.
# Read your credentials from the environment - never paste an API key
# into a shell command, where it lands in your history and process list.
# export MOCHA_TENANT=...
# export MOCHA_API_KEY=...
curl -X GET \
'https://services.us.uat.mochatechnologies.com/signature/api/V1/get-template-by-user?user_id=acf28c4584f007dca67b' \
-H "X-Tenant: $MOCHA_TENANT" \
-H "Api-Key: $MOCHA_API_KEY"<?php
$baseUrl = 'https://services.us.uat.mochatechnologies.com/signature/api/V1';
$query = http_build_query(['user_id' => 'acf28c4584f007dca67b']);
$ch = curl_init($baseUrl . '/get-template-by-user?' . $query);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTPHEADER => [
'X-Tenant: ' . getenv('MOCHA_TENANT'),
'Api-Key: ' . getenv('MOCHA_API_KEY'),
],
]);
$response = curl_exec($ch);
$status = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
// The HTTP status code is authoritative - branch on it, not on the body.
if ($status !== 200) {
throw new RuntimeException('Mocha Signature returned ' . $status . ': ' . $response);
}
$templates = json_decode($response, true)['data'] ?? [];const BASE_URL =
"https://services.us.uat.mochatechnologies.com/signature/api/V1";
async function callMochaSignature(endpoint, body) {
const response = await fetch(BASE_URL + endpoint, {
method: "POST",
headers: {
"X-Tenant": process.env.MOCHA_TENANT,
"Api-Key": process.env.MOCHA_API_KEY,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(
"Mocha Signature " + response.status + ": " + JSON.stringify(payload),
);
}
return payload;
}
async function callMochaSignatureGet(endpoint, params) {
const query = new URLSearchParams(params);
const response = await fetch(BASE_URL + endpoint + "?" + query, {
method: "GET",
headers: {
"X-Tenant": process.env.MOCHA_TENANT,
"Api-Key": process.env.MOCHA_API_KEY,
},
});
const payload = await response.json();
if (!response.ok) {
throw new Error(
"Mocha Signature " + response.status + ": " + JSON.stringify(payload),
);
}
return payload;
}
const { data: templates } = await callMochaSignatureGet("/get-template-by-user", {
user_id: "acf28c4584f007dca67b",
});import os
import requests
BASE_URL = "https://services.us.uat.mochatechnologies.com/signature/api/V1"
HEADERS = {
"X-Tenant": os.environ["MOCHA_TENANT"],
"Api-Key": os.environ["MOCHA_API_KEY"],
}
response = requests.get(
BASE_URL + "/get-template-by-user",
headers=HEADERS,
params={"user_id": "acf28c4584f007dca67b"},
timeout=30,
)
response.raise_for_status()
templates = response.json()["data"]Read the template first, send second. The recipient roles you supply must match the template exactly, so never hard-code them — read them from /get-template-detail and map your own recipients onto them.
// 1. Read the template so you know exactly which roles it expects.
const detail = await callMochaSignatureGet("/get-template-detail", {
template_id: "73766101002022",
});
const { required_recipient, recipients_role } = detail.data;
// required_recipient: 2
// recipients_role: ["Signer 1", "Approver"]
// 2. Send the envelope. One entry per role, in the template's own order.
const sent = await callMochaSignature("/send-envelop", {
user_id: "acf28c4584f007dca67b",
company_name: "Your Company",
email_subject: "Please sign the Non-Disclosure Agreement",
message: "Kindly review and sign the attached document.",
template_id: "73766101002022",
recipients_role: [
{ role: "Signer 1", name: "John Doe", email: "john.doe@example.com" },
{ role: "Approver", name: "Alice Smith", email: "alice.smith@example.com" },
],
metadata: { order_id: "ORD-10021", source: "crm" },
});
// 3. Persist envelope_id - it is the key for tracking and for every webhook.
await db.orders.update("ORD-10021", { envelopeId: sent.envelope_id });
// 4. Poll tracking when you need the current state (webhooks push the final one).
const tracking = await callMochaSignatureGet("/envelop-tracking", {
envelop_id: sent.envelope_id,
});envelope_id from /send-envelop is the key for tracking and appears in every webhook payload for that envelope. Persist it against your own record before you do anything else.How to read the bodies you get back.
POST with a JSON body — including the read-only ones such as /get-template-detail.Content-Type: application/json alongside the two authentication headers.Responses are JSON. The shape varies slightly between endpoints as a legacy of the platform's growth: some return status as a boolean plus a separate status_code, others return status as the numeric HTTP code.
data for the payload. Do not write logic that depends on status being a boolean or a number.Validation failures return 422 with msg holding a field → messages map. Surface those messages directly; they name the offending field.
{
"status": false,
"msg": {
"template_id": ["The template id field is required."]
}
}Get the signed document pushed to you instead of polling.
Webhooks are configured in the app, not over the API. Go to Settings → Custom Webhook → + Add Custom Configuration and provide a name, your endpoint URL, and the events it should fire on.
| Event | Fires when |
|---|---|
document.completed | Every required recipient has completed the envelope. Carries a pre-signed link to the finished PDF, valid for 15 minutes. |
document.declined | A recipient declines or rejects the envelope. The signing flow stops there — remaining recipients are not asked to sign. |
A webhook receives events only for envelopes sent by the user who configured it. Matching is on tenant + sending user + event type.
In practice: the user signed in when you create the configuration must be the same user whose id you pass as user_id to POST /send-envelop. Your application key's owner is that user — the user_id on the key record is the value to use.
If your integration sends on behalf of several users, each sending user needs their own webhook configuration. There is currently no tenant-wide subscription that catches every sender in an account.
user_id used in /send-envelop. Nothing in the API response reports it. Check that the two match before investigating anything else, then confirm the configuration is active and the event is subscribed.| Header | Description |
|---|---|
X-Webhook-Event | Event type, for example document.completed. |
X-Webhook-Delivery-Id | Unique per delivery. Store it — it is the lookup key for the Deliveries endpoint. |
X-Webhook-Timestamp | ISO-8601 send time. |
X-Authorization-Digest | Always HMACSHA256. |
X-Webhook-Signature-1 | Base64 HMAC-SHA256 of the raw body, computed with active secret #1. |
X-Webhook-Signature-2 | Present when a second secret is active (during rotation). |
{
"event": "document.completed",
"data": {
"user_id": "acf28c4584f007dca67b",
"envelop_id": "b5db5fe9-8a44-4ecc-b3f4-222b14b1d5dc",
"status": "completed",
"generated_at": "2026-08-03T10:24:11.000000Z",
"metadata": {
"order_id": "ORD-10021",
"source": "crm"
},
"document": {
"file_name": "b5db5fe9-8a44-4ecc-b3f4-222b14b1d5dc.pdf",
"download_url": "https://s3.amazonaws.com/...?X-Amz-Expires=900&X-Amz-Signature=...",
"mime_type": "application/pdf"
}
},
"timestamp": "2026-08-03T10:24:11.482000Z"
}data.metadata echoes the metadata object you passed to /send-envelop. It is null for any envelope created in the web app, because only the API path writes it — handle null rather than assuming your keys are present./send-envelop returns the id as envelope_id, while the webhook payload carries the same value as data.envelop_id. Same identifier, different field name./envelop-tracking on receipt./envelop-tracking to catch up on anything sent during a pause.data.document.download_url is a pre-signed S3 link valid for 15 minutes. Download and store the file when you receive the event — never persist the URL itself.HMAC-SHA256 over the raw request body.
Every delivery is signed with HMAC-SHA256 over the raw request body. You may receive one or more X-Webhook-Signature-* headers. Compute the digest with each of your active secrets and accept the request if any signature matches.
php://input in PHP, express.raw() in Express, request.body (bytes) in Django/FastAPI.The payload examples on this page are formatted for reading. The real request body is compact — no newlines, no indentation — with forward slashes unescaped (https://…, never https:\/\/…) and no trailing newline. The signature is computed over exactly those bytes.
{"event":"document.completed","data":{"user_id":"acf28c4584f007dca67b","envelop_id":"b5db5fe9-8a44-4ecc-b3f4-222b14b1d5dc","status":"completed","generated_at":"2026-08-03T10:24:11.000000Z","metadata":{"order_id":"ORD-10021","source":"crm"},"document":{"file_name":"b5db5fe9-8a44-4ecc-b3f4-222b14b1d5dc.pdf","download_url":"https://s3.amazonaws.com/...","mime_type":"application/pdf"}},"timestamp":"2026-08-03T10:24:11.482000Z"}This is why you must hash the raw request stream and never a re-serialised object: your encoder will almost certainly produce different bytes — Python and PHP add a space after : and ,, and PHP's default json_encode escapes slashes — and the digest will not match.
<?php
// Read the RAW body. Re-encoding the JSON changes the digest.
$rawBody = file_get_contents('php://input');
$incomingSignatures = [
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE_1'] ?? null,
$_SERVER['HTTP_X_WEBHOOK_SIGNATURE_2'] ?? null,
];
// Load the secret from configuration - never hard-code it in the handler.
$secret = getenv('MOCHA_WEBHOOK_SECRET');
$computed = base64_encode(hash_hmac('sha256', $rawBody, $secret, true));
$valid = false;
foreach ($incomingSignatures as $signature) {
if ($signature && hash_equals($computed, $signature)) {
$valid = true;
break;
}
}
if (!$valid) {
http_response_code(401);
exit('Invalid signature');
}
$event = json_decode($rawBody, true);
// Acknowledge immediately, then process out of band.
http_response_code(200);
echo json_encode(['received' => true]);const crypto = require("crypto");
const express = require("express");
const app = express();
// Your active secret keys - up to two while rotating.
const SECRETS = [
process.env.MOCHA_WEBHOOK_SECRET_1,
process.env.MOCHA_WEBHOOK_SECRET_2,
].filter(Boolean);
function isValidSignature(rawBody, headers) {
const incoming = [
headers["x-webhook-signature-1"],
headers["x-webhook-signature-2"],
].filter(Boolean);
return incoming.some((signature) =>
SECRETS.some((secret) => {
const computed = crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("base64");
const a = Buffer.from(computed);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}),
);
}
// express.raw keeps the exact bytes - never verify JSON.stringify(req.body).
app.post(
"/hooks/mocha-signature",
express.raw({ type: "application/json" }),
(req, res) => {
if (!isValidSignature(req.body, req.headers)) {
return res.status(401).send("Invalid signature");
}
const event = JSON.parse(req.body.toString("utf8"));
res.sendStatus(200); // acknowledge fast
queue.add("mocha-signature", event); // process asynchronously
},
);What happens when your endpoint is down.
Delivery is retried up to four attempts. Respond 2xx quickly to acknowledge — anything else, or a timeout past 15 seconds, marks the delivery failed and schedules the next attempt.
| Attempt | Sent after | Elapsed |
|---|---|---|
| 1 | immediately | 0 |
| 2 | 60 seconds | ~1 min |
| 3 | 5 minutes | ~6 min |
| 4 (last) | 15 minutes | ~21 min |
2xx. A fully failing delivery can therefore reach your endpoint up to 12 times, and three of those can arrive inside the same second. Deduplicate on data.envelop_id plus event so repeats are ignored safely.data.envelop_id plus event, and ignore anything you have already seen. Do not deduplicate on X-Webhook-Delivery-Id: that id is unique per delivery attempt, so every retry carries a different one and you would process the same completion up to four times. Store it for diagnostics — it is the lookup key for the Deliveries endpoint — never as your dedupe key.POST /webhook/deliveries with a delivery id to see exactly what was sent, what your endpoint returned, and how long it took. Each attempt is its own record, so look up the id from the attempt you want to inspect./envelop-tracking always reflects the current state — it is the reliable fallback./envelop-tracking reconciliation is not optional in production: poll it for envelopes you have sent but not yet seen a terminal event for, and treat the webhook as the fast path rather than the only path.Status codes and the fix for each.
| Status | Meaning |
|---|---|
| 200 | Success. Read the payload from data. |
| 201 | Created — returned by Insert Envelope Template Fields on success. |
| 204 | No template maps to the supplied template_id. Per HTTP semantics the body is not transmitted, so branch on the status code, not the body. |
| 401 | Api-Key missing, revoked, or not owned by the tenant in X-Tenant. |
| 404 | The tenant in X-Tenant does not exist, the key's user has no email on record, the envelope / delivery id was not found, or Send Envelope's user_id / template_id does not match any existing record. |
| 422 | Validation failed, a required field was missing, or — on Send Envelope — recipients_role does not map onto the template's roles (wrong count, unknown role, or wrong order for a sequence template). msg holds a field → messages map naming what is wrong. |
| 500 | An unexpected error occurred while processing the request. error carries the reason. |
| 503 | A dependency needed to authenticate the request was unreachable. Safe to retry after a short backoff. |
| Symptom | Cause and fix |
|---|---|
422 — Recipients role can not map with required template roles. | Your recipients_role entries do not match the template. Call /get-template-detail and make sure the number of entries equals required_recipient and every role string appears in the template's recipients_role list, spelled identically. For a sequence template they must also be in the template's own signing order — role 1 first. |
404 — Template [...] does not exist / User [...] does not exist | template_id or user_id does not match any record for the current tenant. Double-check the id and that you are calling the right environment (UAT vs. production). |
422 — Could not provision recipient [...] | A recipient in recipients_role doesn't have an account yet, and the identity service rejected the new-user details — most commonly an email address it considers undeliverable (e.g. @example.com). Use a real, deliverable recipient email. |
| 422 on Send Envelope with no obvious missing field | message is documented as optional but enforced downstream. Always send it, along with user_id, company_name, email_subject, template_id and recipients_role. |
| 404 from Envelope Tracking with an id that just worked | The request field is envelop_id (single e) while Send Envelope returns it as envelope_id. Map the value across the spelling difference. |
pdf_preview returns 403 when fetched | Pre-signed template URLs are valid for 10 minutes. Fetch promptly or re-request the list. A null value means the object is missing from storage. |
| Webhook signature never matches | You are hashing a re-encoded body. Verify against the raw bytes, and check both X-Webhook-Signature-1 and X-Webhook-Signature-2 during a key rotation. |
| Intermittent 503s | An upstream authentication dependency was briefly unreachable. Retry with exponential backoff; the request was not processed. |
| Envelope completes but the endpoint is never called | Almost always a sender mismatch — the webhook was configured by a different user than the user_id used in /send-envelop. Also confirm the configuration is active and the event is subscribed. |
| Signature verifies live but not against a captured sample | The captured body was reformatted. Pretty-printers, copy-as-JSON buttons and editors that append a trailing newline all change the bytes. Re-capture raw. |
| The same completion is processed several times | You are deduplicating on X-Webhook-Delivery-Id, which is unique per attempt. Key on data.envelop_id plus event. |
What production integrations get right.
envelope_id on send and X-Webhook-Delivery-Id on receipt — the first is your tracking key, the second your debugging key. Deduplicate on data.envelop_id plus event, never on the delivery id.metadata for correlation. Anything you put there is echoed back verbatim in every webhook, which lets you match an event to your own order or case without a lookup table.recipients_role before each send stops a template edit from breaking your integration./envelop-tracking, which exposes per-recipient status, viewed_at and completed_at.