Introduction
Public REST API for Contract creation and Electronic Signature lifecycle. Bilingual (fa/en) errors. RFC 7807 problem+json envelope.
## Quickstart
1. **Create a Personal Access Token** in your Emza dashboard at `/dashboard/api-tokens`. The plain token is shown **once at creation** — copy and store it securely.
2. The token format is `{id}|{secret}` (Sanctum Personal Access Token), e.g. `42|abcdef1234567890…`. Send it verbatim — no `pat_live_` prefix, no extra wrapping.
3. Send any API request with `Authorization: Bearer 42|abcdef1234567890…`.
4. Your token's **mode** (Live vs. Test/Sandbox) is shown in the `data.token.mode` field of `GET /api/v1/me`. Sandbox tokens never charge your wallet — usage is no-op.
## Headers
| Header | Purpose |
|--------|---------|
| `Authorization: Bearer <token>` | Required on all authenticated routes |
| `Idempotency-Key: <key>` | Required on every POST/PATCH/DELETE (see Idempotency section) |
| `Accept: application/json` | Recommended; ensures JSON responses on all paths |
| `X-Skip-Sms: true` | (POST /contracts only) skip emza's outbound SMS; you deliver signing_url yourself |
## Idempotency
All `POST`, `PATCH`, `PUT`, `DELETE` endpoints support **Idempotency-Key** for safe retries.
- **Format:** `[A-Za-z0-9_-]+`, max 64 chars (e.g. `pay-2026-05-13-001` or any ULID).
- **TTL:** 24 hours. Within that window, replaying the same key + same body returns the **original response** with `X-Idempotent-Replay: true` (status code preserved).
- **Body conflict:** Same key with a different body returns `409 idempotency_conflict`.
- **Malformed key:** 65+ chars or characters outside `[A-Za-z0-9_-]` returns `400 malformed_idempotency_key`.
- **Missing key:** Allowed for backwards compat — request proceeds without replay protection. Recommended to always send one.
## Rate limits
Per-token, tier-aware. Every response includes `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` headers. When exceeded, the response is `429 rate_limit_exceeded` with `Retry-After` header.
| Tier | Requests / minute |
|------|------:|
| Anonymous (`/health`, signed download URL) | 60 (per IP) |
| Free / no active package | 30 (per token) |
| Bronze | 120 |
| Silver | 600 |
| Gold | 3,000 |
## Response envelope
**Success** — wrapped in `data`:
```json
{ "data": { ... } }
```
Paginated lists add `meta` + `links`:
```json
{
"data": [...],
"meta": { "current_page": 1, "per_page": 20, "total": 142 },
"links": { "next": "https://elemza.com/api/v1/contracts?page=2" }
}
```
**Error** — RFC 7807 problem+json envelope with bilingual titles:
```json
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRH69QCTCWD0RWP2NRR05VZ5"
}
```
Field semantics:
- `type` — stable URI identifying the error class. Treat as constant; UI may follow for help docs.
- `status` — HTTP status code (duplicates the response line; convenient for log pipelines).
- `code` — machine-readable error identifier. **Always present.** Use this for branching.
- `title_fa` / `title_en` — human-readable message in both languages. Safe to display directly to end-users.
- `request_id` — ULID echoed in `X-Request-ID` response header. Quote this when contacting support.
Validation errors additionally include `errors` (field-keyed array of messages):
```json
{
"status": 422, "code": "validation",
"errors": { "url": ["فقط آدرسهای HTTPS مجاز هستند."] },
...
}
```
## Error codes catalog
| code | HTTP | Meaning |
|------|:----:|---------|
| `token_missing` | 401 | `Authorization: Bearer …` header is missing or empty |
| `token_invalid` | 401 | Token doesn't match any active row (typo, deleted, malformed) |
| `token_revoked` | 401 | Token was revoked (manually or auto after 5 IP-whitelist strikes) |
| `token_expired` | 401 | Token's `expires_at` is in the past |
| `ip_not_allowed` | 403 | Request IP doesn't match the token's IP whitelist |
| `scope_missing` | 403 | Token lacks the ability needed for this endpoint (e.g. `signers:read_pii` for PII filters) |
| `not_found` | 404 | Resource doesn't exist, was deleted, or isn't owned by the caller |
| `method_not_allowed` | 405 | HTTP method not supported on this path |
| `idempotency_conflict` | 409 | Idempotency-Key was already used with a different request body |
| `validation` | 422 | Body or query parameters failed validation (see `errors` field) |
| `insufficient_balance` | 422 | Wallet balance too low for the requested operation |
| `malformed_idempotency_key` | 400 | Idempotency-Key value has wrong format |
| `rate_limit_exceeded` | 429 | Tier limit reached. Retry after `Retry-After` seconds |
| `internal_error` | 500 | Unexpected server fault. `request_id` is logged on our side; report when frequent |
## Webhooks
Subscribe to async events via `POST /webhooks`. We deliver an HTTP `POST` to your URL with HMAC-SHA256 signature in `X-Emza-Signature: t=<unix>,v1=<hex>`.
**Event names** (set in `events` array; use `["*"]` for all):
| Event | When |
|-------|------|
| `contract.created` | Contract record created (before file processing) |
| `contract.processing.completed` | Background file → PDF job finished |
| `contract.processing.failed` | File processing failed; reason in payload |
| `contract.signer.added` | New signer attached to contract |
| `contract.signer.authenticated` | Signer completed OTP / KYC |
| `contract.signer.signed` | Signer placed signature/stamp |
| `contract.signer.rejected` | Signer canceled their participation |
| `contract.completed` | All signers signed + signed PDF generated |
| `contract.canceled` | Owner canceled the contract |
| `contract.refunded` | Cost refunded to wallet (auto on cancel) |
**URL restrictions (SSRF guard):** webhook URLs are validated server-side before save. The following are rejected with `422`:
- Non-HTTPS schemes (`http://`, `ftp://`, `javascript:`, `file://`)
- Loopback IPs (`127.0.0.0/8`, `::1`)
- RFC 1918 private (`10/8`, `172.16/12`, `192.168/16`)
- Link-local + cloud metadata (`169.254.0.0/16`, `100.100.100.200`)
- IPv6 link-local (`fe80::/10`) and unique-local (`fc00::/7`)
- Hostnames ending in `.local`, `.internal`, `.private`, `.lan`, or `localhost`
- Hosts whose DNS resolves to any of the above
**Verifying signatures:** see `app/Services/Webhook/` examples or the Webhook section below.
<aside>Full architecture + decision log lives at <code>docs/api-master-plan/</code> in the Emza repo. Stakeholder-locked plan as of 2026-05-11.</aside>
Authenticating requests
To authenticate requests, include an Authorization header with the value "Bearer 42|abcdef1234567890abcdef1234567890abcdef".
All authenticated endpoints are marked with a requires authentication badge in the documentation below.
Create your token in the dashboard at /dashboard/api-tokens. The plain token is shown once at creation. Format is {id}|{secret} — send it verbatim in the Authorization header, no patlive prefix.
Contracts
Public signed-URL download.
requires authentication
Validates Laravel's signed middleware. No Bearer token required —
the URL signature is the auth proof. The signature is generated by
downloadPdf() after the bearer was verified, so this is functionally
equivalent to a 24h time-bounded capability URL.
Records an ApiRequestLog-style entry via the underlying ContractDownload
audit table (same trail as the user-panel signed-link download flow).
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/contracts/consequatur/dl" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/dl"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (404):
Show headers
x-request-id: 01KRHE7DDE9DMAW383Z6SS09BY
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/not_found",
"status": 404,
"code": "not_found",
"title_fa": "منبع یافت نشد",
"title_en": "Resource not found",
"request_id": "01KRHE7DDE9DMAW383Z6SS09BY"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List contracts
requires authentication
Returns paginated contracts visible to caller. Org root sees own + descendants' non-private contracts; non-root sees own + contracts where they're a signer.
Scope: contracts:read.
PII filter scope requirement: filtering by signer_mobile or
signer_national_code requires the additional signers:read_pii ability
on your token. Without it, those query parameters return
403 scope_missing. This prevents a low-privilege contracts:read
token from using the index as a confirmation oracle to check whether a
given mobile/national-code signs any contract on the owner's tree.
The * wildcard scope also passes the check.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/contracts?status=waiting_signature&created_after=2026-01-01T00%3A00%3A00Z&created_before=consequatur&signer_mobile=09121234567&signer_national_code=consequatur&template_id=17&is_private=&page=17&per_page=17&sort=consequatur" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"status\": \"waiting_signature\",
\"created_after\": \"2026-05-13T23:20:09\",
\"created_before\": \"2026-05-13T23:20:09\",
\"signer_mobile\": \"09156277171\",
\"signer_national_code\": \"sufvyvddqa\",
\"template_id\": 17,
\"is_private\": true,
\"page\": 45,
\"per_page\": 16,
\"sort\": \"created_at\"
}"
const url = new URL(
"http://localhost:8000/api/v1/contracts"
);
const params = {
"status": "waiting_signature",
"created_after": "2026-01-01T00:00:00Z",
"created_before": "consequatur",
"signer_mobile": "09121234567",
"signer_national_code": "consequatur",
"template_id": "17",
"is_private": "0",
"page": "17",
"per_page": "17",
"sort": "consequatur",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"status": "waiting_signature",
"created_after": "2026-05-13T23:20:09",
"created_before": "2026-05-13T23:20:09",
"signer_mobile": "09156277171",
"signer_national_code": "sufvyvddqa",
"template_id": 17,
"is_private": true,
"page": 45,
"per_page": 16,
"sort": "created_at"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DG7JVYWGBT0X6YJ1FNF
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DG7JVYWGBT0X6YJ1FNF"
}
Example response (403, PII filter without scope):
{
"type": "https://docs.elemza.com/errors/scope_missing",
"status": 403,
"code": "scope_missing",
"title_fa": "این فیلتر نیاز به مجوز signers:read_pii دارد",
"title_en": "This filter requires the signers:read_pii scope",
"request_id": "01KRH8JRC4Y855P10CYC1C0AYS"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get contract
requires authentication
Returns full contract shape with embedded signers, per-page geometry, and template positions snapshot.
Scope: contracts:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/contracts/ABC1234567XYZ890" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/ABC1234567XYZ890"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DGV0VQ6SYZH0G2SCRKR
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DGV0VQ6SYZH0G2SCRKR"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Download signed PDF
requires authentication
Returns the final signed PDF in one of three formats (Phase 3):
format=redirect(default) —302 Foundto a signed URL (24h TTL). The signed URL points at the unauthenticated/api/v1/contracts/{code}/dlroute, which validates the signature and streams the file. Use this when embedding in<a href>for partner-facing UIs.format=url— JSON envelope withurl,expires_at,filename,size_bytes,checksum_sha256. Use this when the API client wants to hand the URL to another system, embed in email, or pre-flight before downloading.format=stream— streams the PDF inline (consumes API egress). Use when the caller is a backend service that wants the bytes in one round-trip.
Scope: contracts:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/contracts/consequatur/pdf?format=url&ttl=17" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/pdf"
);
const params = {
"format": "url",
"ttl": "17",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (404):
Show headers
x-request-id: 01KRHE7DH84BEG7K28XNHF2CJW
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/not_found",
"status": 404,
"code": "not_found",
"title_fa": "منبع یافت نشد",
"title_en": "Resource not found",
"request_id": "01KRHE7DH84BEG7K28XNHF2CJW"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Poll processing status
requires authentication
Lightweight (<200 bytes) status of the rasterization + signer-creation job.
Reads contract:progress:{id} cache key directly — no DB hit.
Step values: files, signers, sms, done, error.
Returns ready=true when step=done. If progress key is gone (TTL expired)
and contract has signers + pages, falls back to ready=true (job finished
earlier and key was reaped).
Scope: contracts:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/contracts/consequatur/processing" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/processing"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (404):
Show headers
x-request-id: 01KRHE7DHN9ADP8DKGHD6GCYTV
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/not_found",
"status": 404,
"code": "not_found",
"title_fa": "منبع یافت نشد",
"title_en": "Resource not found",
"request_id": "01KRHE7DHN9ADP8DKGHD6GCYTV"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create contract
requires authentication
Creates a new contract. Two modes:
One-shot — multipart/form-data with file field + metadata. File is
persisted to staging and ProcessContractFilesJob is dispatched. Optional
signers[] are added by the same job after page rasterization completes.
Response is 202 Accepted; poll _links.processing until ready=true.
Multi-step — JSON without file. Creates a draft contract row only.
Caller then issues POST /contracts/{code}/files to attach the document.
Scope: contracts:write.
Example request:
curl --request POST \
"http://localhost:8000/api/v1/contracts" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "title=قرارداد بیمه شخص ثالث"\
--form "all_pages_signature="\
--form "is_private="\
--form "template_id=17"\
--form "template_enforcement=consequatur"\
--form "signers[]=consequatur"const url = new URL(
"http://localhost:8000/api/v1/contracts"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('title', 'قرارداد بیمه شخص ثالث');
body.append('all_pages_signature', '');
body.append('is_private', '');
body.append('template_id', '17');
body.append('template_enforcement', 'consequatur');
body.append('signers[]', 'consequatur');
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Upload contract file(s) (two-step flow)
requires authentication
Attaches a file to an existing draft contract that has no pages yet.
Same async pipeline as POST /contracts one-shot: stages bytes, dispatches
ProcessContractFilesJob, returns 202 with the processing link.
Scope: contracts:write.
Example request:
curl --request POST \
"http://localhost:8000/api/v1/contracts/consequatur/files" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: multipart/form-data" \
--header "Accept: application/json" \
--form "file=@C:\Users\pc\AppData\Local\Temp\php3138.tmp" const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/files"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "multipart/form-data",
"Accept": "application/json",
};
const body = new FormData();
body.append('file', document.querySelector('input[name="file"]').files[0]);
fetch(url, {
method: "POST",
headers,
body,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Cancel contract
requires authentication
Cancels a contract in draft or waiting_signature state. Refunds the
billed amount to the original wallet (100% if no signer signed, 50% if any
Shahkar was consumed). Deletes contract files from storage. Waiting signers
receive an SMS notifying them the contract was canceled.
Scope: contracts:write.
Example request:
curl --request DELETE \
"http://localhost:8000/api/v1/contracts/consequatur" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Example response (200):
{
"data": {
"code": "ABC1234567XYZ890",
"status": "canceled",
"canceled_at": "2026-05-11T10:00:00.000Z",
"refund": {
"amount": 60000,
"currency": "IRT"
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Forms — read-only access to v2 forms owned by the authenticated user.
Phase 5 MVP: list + show + stats. Submissions endpoint will land in Phase 5b with proper pagination + transformer.
List forms (v2 only — schema_version='2.0')
requires authentication
Returns up to 100 most recent v2-schema forms owned by the calling user. Form Builder v1 records are excluded — use the dashboard for those.
Scope required: forms:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/forms" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/forms"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"data": [
{
"id": 142,
"slug": "frm-e7465df28dd45785",
"title": "قرارداد همکاری",
"status": "published",
"renderer_type": "online",
"submission_count": 23,
"views_count": 187,
"created_at": "2026-05-10T14:32:00.000Z"
}
]
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Response
Response Fields
data
object
status
One of draft, published, archived.
renderer_type
Form rendering mode: online (web form) or image_overlay (PDF background + overlay).
Show one form (metadata only — schema available via dedicated /schema endpoint in future)
requires authentication
Scope required: forms:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/forms/frm-e7465df28dd45785" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/forms/frm-e7465df28dd45785"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"data": {
"id": 142,
"slug": "frm-e7465df28dd45785",
"title": "قرارداد همکاری",
"status": "published",
"renderer_type": "online",
"submission_count": 23,
"views_count": 187,
"created_at": "2026-05-10T14:32:00.000Z"
}
}
Example response (404, Not found or not owned):
{
"type": "https://docs.elemza.com/errors/form_not_found",
"status": 404,
"code": "form_not_found",
"title_fa": "فرم یافت نشد یا متعلق به شما نیست",
"title_en": "Form not found or not owned by the caller",
"request_id": "01KRH8JRC4Y855P10CYC1C0AYS"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Form analytics (views/starts/submissions/conversion rates).
requires authentication
Aggregated counters returned by FormAnalyticsService::stats(). Includes
lifetime totals plus 7-day and 30-day windows so you can chart trends without
additional queries.
Scope required: forms:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/forms/frm-e7465df28dd45785/stats" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/forms/frm-e7465df28dd45785/stats"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"data": {
"totals": {
"views": 187,
"starts": 41,
"submissions": 23,
"conversion_rate": 56.1
},
"last_7_days": {
"views": 32,
"starts": 9,
"submissions": 4
},
"last_30_days": {
"views": 154,
"starts": 38,
"submissions": 21
}
}
}
Example response (404, Not found or not owned):
{
"type": "https://docs.elemza.com/errors/form_not_found",
"status": 404,
"code": "form_not_found",
"title_fa": "فرم یافت نشد یا متعلق به شما نیست",
"title_en": "Form not found or not owned by the caller",
"request_id": "01KRH8JRC4Y855P10CYC1C0AYS"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Meta — health, identity, quota, cost
Health check
Lightweight liveness probe. No authentication required. Always returns 200 if the API process is up.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/health" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/health"
);
const headers = {
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200):
{
"status": "ok",
"time": "2026-05-11T11:53:29.399Z",
"version": "v1.0.0"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Caller identity
requires authentication
Returns information about the authenticated token, its owner user, and the billing context (wallet balance, org root). Any valid token can call this — no specific scope required.
Use the token.mode field to confirm whether your token is in live mode
(charges your wallet, real KYC/SMS) or test sandbox mode (zero wallet
impact, mocked side effects). wallet_balance is always in tomans (IRR/10).
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/me" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/me"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Live token):
{
"data": {
"token": {
"id": "42",
"name": "production-server-2026",
"mode": "live",
"scopes": [
"contracts:read",
"contracts:write",
"webhooks:manage"
],
"ip_whitelist": [
"203.0.113.5/32"
],
"expires_at": "2027-01-01T00:00:00.000Z",
"last_used_at": "2026-05-13T18:00:42.000Z"
},
"user": {
"id": 1234,
"name": "آرش بنائیان چمله",
"type": "personal",
"is_org_root": false,
"org_root_id": null
},
"billing_actor_user_id": 1234,
"wallet_balance": 849310
}
}
Example response (200, Sandbox/test token):
{
"data": {
"token": {
"id": "13",
"name": "ci-tests-sandbox",
"mode": "test",
"scopes": [
"*"
],
"ip_whitelist": [],
"expires_at": null,
"last_used_at": "2026-05-13T17:52:23.000Z"
},
"user": {
"id": 1234,
"name": "آرش بنائیان چمله",
"type": "personal",
"is_org_root": false,
"org_root_id": null
},
"billing_actor_user_id": 1234,
"wallet_balance": 849310
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Response
Response Fields
data
object
token
object
mode
Either live or test. Sandbox tokens never debit the wallet.
scopes
string[]
of granted abilities. ["*"] means full access.
ip_whitelist
If non-empty, requests from other IPs are rejected with 403 ip_not_allowed. Five failures within 1h auto-revoke the token.
user
object
is_org_root
True when this user is the root of a Sub-Organization tree (can invite sub-users).
org_root_id
ID of the org root if this user is a member, else null.
billing_actor_user_id
The wallet that pays for this request. Same as user.id for solo users; may be org_root_id for org members with auto_charge_from_parent=true.
wallet_balance
Current wallet balance in tomans (Iranian Rials ÷ 10).
Current quota
requires authentication
Returns the caller's current-month signature quota usage + remaining, package tier, and today's Shahkar verification credits.
Quota semantics:
free_quotais the monthly allowance from the user's active package (Free=3, Bronze=30, Silver=100, Gold=200). Returns 0 if the package is expired or unset.usedis total signatures recorded sincepackage_started_at(so pre-purchase usage doesn't deplete a freshly-bought plan).remaining = free_quota - usedfor limited plans. null means genuinely unlimited (Gold-tiersignature_limitis NULL).- When
remainingreaches 0, signatures still work — they're billed as overage from the wallet at the per-unit tariff rate minus the package discount (Free=0%, Bronze=20%, Silver=35%, Gold=50%).
Shahkar credits are separate from signature quota. Daily allowance by package (Free=3, Bronze=5, Silver=10, Gold=20). Once exhausted, the client must purchase batch top-ups before further Shahkar verifications.
Scope required: contracts:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/quota" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/quota"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (200, Bronze user mid-month):
{
"data": {
"service": "electronic_signature",
"period_start": "2026-05-01T00:00:00.000Z",
"period_end": "2026-05-31T23:59:59.999Z",
"package": {
"slug": "bronze",
"name": "برنزی"
},
"free_quota": 30,
"used": 17,
"remaining": 13,
"shahkar": {
"free_limit": 5,
"used_today": 2,
"free_remaining": 3,
"extra_credits": 0,
"extra_remaining": 0,
"allowed": true
}
}
}
Example response (200, Gold (unlimited)):
{
"data": {
"service": "electronic_signature",
"period_start": "2026-05-01T00:00:00.000Z",
"period_end": "2026-05-31T23:59:59.999Z",
"package": {
"slug": "gold",
"name": "طلایی"
},
"free_quota": 0,
"used": 412,
"remaining": null,
"shahkar": {
"free_limit": 20,
"used_today": 4,
"free_remaining": 16,
"extra_credits": 0,
"extra_remaining": 0,
"allowed": true
}
}
}
Example response (200, No active package (pay-per-use)):
{
"data": {
"service": "electronic_signature",
"period_start": "2026-05-01T00:00:00.000Z",
"period_end": "2026-05-31T23:59:59.999Z",
"package": null,
"free_quota": 0,
"used": 0,
"remaining": 0,
"shahkar": {
"free_limit": 0,
"used_today": 0,
"free_remaining": 0,
"extra_credits": 0,
"extra_remaining": 0,
"allowed": false
}
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Response
Response Fields
data
object
free_quota
Monthly signature allowance from active package. Zero if package expired or none.
used
Signatures recorded this period (since package_started_at).
remaining
Signatures left this period. null means unlimited (Gold).
shahkar
object
allowed
Whether the user may attempt a Shahkar verification right now. False = quota exhausted, must purchase batch top-up.
Cost estimate
requires authentication
Returns full cost breakdown for a hypothetical contract without committing.
Use before calling POST /contracts so users can see "X toman" upfront.
Scope required: contracts:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/cost-estimate?signers=3&discount_code=WELCOME10" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"signers\": 17,
\"discount_code\": \"mqeopfuudtdsufvyvddqa\"
}"
const url = new URL(
"http://localhost:8000/api/v1/cost-estimate"
);
const params = {
"signers": "3",
"discount_code": "WELCOME10",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"signers": 17,
"discount_code": "mqeopfuudtdsufvyvddqa"
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DF20PVTV9REMR4NSX0X
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DF20PVTV9REMR4NSX0X"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Signers
Signer endpoints for a contract.
Phase 1 = read-only (index + show). Resend + delete = Phase 2 (mutations).
Spec: docs/api-master-plan/02-endpoint-surface.md (Signer endpoints).
List signers
requires authentication
Lists all signers on a contract. PII (mobile, national_code) is masked
unless the caller's token has signers:read_pii scope.
Scope: contracts:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/contracts/consequatur/signers" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/signers"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (404):
Show headers
x-request-id: 01KRHE7DJA84RJNMAE59Y3SHG6
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/not_found",
"status": 404,
"code": "not_found",
"title_fa": "منبع یافت نشد",
"title_en": "Resource not found",
"request_id": "01KRHE7DJA84RJNMAE59Y3SHG6"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get signer
requires authentication
Detailed view of a single signer. PII masked unless token has signers:read_pii.
Scope: contracts:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/contracts/consequatur/signers/DEF456GHJ789KLM" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/signers/DEF456GHJ789KLM"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (404):
Show headers
x-request-id: 01KRHE7DJRYT32HYXVANCYK7H9
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/not_found",
"status": 404,
"code": "not_found",
"title_fa": "منبع یافت نشد",
"title_en": "Resource not found",
"request_id": "01KRHE7DJRYT32HYXVANCYK7H9"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Add signers
requires authentication
Adds one or more signers to a draft contract. Runs mandatory Shahkar
pre-validation (mobile ↔ national_code match) per signer in production.
On success, contract transitions to waiting_signature and SMS dispatch
jobs are queued for first-order signers.
Scope: signers:write.
Example request:
curl --request POST \
"http://localhost:8000/api/v1/contracts/consequatur/signers" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"signers\": [
\"consequatur\"
]
}"
const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/signers"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"signers": [
"consequatur"
]
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Resend signing link SMS
requires authentication
Re-sends the signing-link SMS to a waiting signer. Rate-limited to 3 per
5 minutes per signer (returns 429 rate_limit_exceeded).
Scope: signers:write.
Example request:
curl --request POST \
"http://localhost:8000/api/v1/contracts/consequatur/signers/consequatur/resend" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/signers/consequatur/resend"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Remove signer
requires authentication
Removes a signer from a contract. Only allowed when:
- Signer status is
waiting(not yet signed) - Contract status is
draftorwaiting_signature
Scope: signers:write.
Example request:
curl --request DELETE \
"http://localhost:8000/api/v1/contracts/consequatur/signers/consequatur" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/contracts/consequatur/signers/consequatur"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Example response (204):
Empty response
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Templates
Template endpoints — read-only in v1.0. CRUD lands in v1.x.
Spec: docs/api-master-plan/02-endpoint-surface.md (Template endpoints).
List templates
requires authentication
Lists contract templates the caller can use.
view_scope options:
self— own templates only (default)shared_from_parent— own + templates shared by org parent/rootall_org— entire org tree (org root only)
Scope: templates:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/templates?view_scope=consequatur&active=&page=17&per_page=17" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"view_scope\": \"all_org\",
\"active\": true,
\"page\": 73,
\"per_page\": 13
}"
const url = new URL(
"http://localhost:8000/api/v1/templates"
);
const params = {
"view_scope": "consequatur",
"active": "0",
"page": "17",
"per_page": "17",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"view_scope": "all_org",
"active": true,
"page": 73,
"per_page": 13
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DMJME92CQKMASDKJ61D
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DMJME92CQKMASDKJ61D"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get template
requires authentication
Full template shape with embedded positions + page geometry + sample preview link.
Scope: templates:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/templates/consequatur" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/templates/consequatur"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (404):
Show headers
x-request-id: 01KRHE7DN4JMZR465W2N17C9K9
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/not_found",
"status": 404,
"code": "not_found",
"title_fa": "منبع یافت نشد",
"title_en": "Resource not found",
"request_id": "01KRHE7DN4JMZR465W2N17C9K9"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Template page preview
requires authentication
Returns a signed URL to a single page preview image (PNG).
Phase 1 stub — url is null until Phase 3 wires Storage::temporaryUrl().
Scope: templates:read.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/templates/17/preview/17" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/templates/17/preview/17"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DNG28DQGAKXHX883JVR
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DNG28DQGAKXHX883JVR"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Webhooks
Outbound webhook subscriptions. Customers create one or more endpoints, each filtered by an array of event names. Deliveries are signed with HMAC-SHA256 via the secret returned at create-time (shown only once).
Scope: webhooks:manage.
List webhook subscriptions
requires authentication
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/webhooks?active=&page=17&per_page=17" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"active\": false,
\"page\": 73,
\"per_page\": 13
}"
const url = new URL(
"http://localhost:8000/api/v1/webhooks"
);
const params = {
"active": "0",
"page": "17",
"per_page": "17",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"active": false,
"page": 73,
"per_page": 13
};
fetch(url, {
method: "GET",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DQQPQXJC957HJXTSJPZ
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DQQPQXJC957HJXTSJPZ"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get webhook subscription
requires authentication
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/webhooks/AYbBkm7h8gnApxq66c0TVGHFAb" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/webhooks/AYbBkm7h8gnApxq66c0TVGHFAb"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DR64B5HAJJ7AG41E6TN
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DR64B5HAJJ7AG41E6TN"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
List webhook deliveries
requires authentication
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/webhook-deliveries?subscription_id=consequatur&event_name=consequatur&status=consequatur&page=17&per_page=17" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/webhook-deliveries"
);
const params = {
"subscription_id": "consequatur",
"event_name": "consequatur",
"status": "consequatur",
"page": "17",
"per_page": "17",
};
Object.keys(params)
.forEach(key => url.searchParams.append(key, params[key]));
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DS0PCFV98AY90DA5RW1
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DS0PCFV98AY90DA5RW1"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Get webhook delivery
requires authentication
Shows full payload, response code, error message, attempt history.
Example request:
curl --request GET \
--get "http://localhost:8000/api/v1/webhook-deliveries/AYbBkm7h8gnApxq66c0TVGHFAb" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/webhook-deliveries/AYbBkm7h8gnApxq66c0TVGHFAb"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "GET",
headers,
}).then(response => response.json());Example response (401):
Show headers
x-request-id: 01KRHE7DS93WY28DHGGEMC5B1W
x-emza-api-version: v1
cache-control: no-cache, private
content-type: application/json
access-control-allow-origin: *
access-control-expose-headers: X-Request-ID, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, Retry-After, X-Emza-API-Version, X-Idempotent-Replay
{
"type": "https://docs.elemza.com/errors/token_invalid",
"status": 401,
"code": "token_invalid",
"title_fa": "توکن نامعتبر است",
"title_en": "Invalid token",
"request_id": "01KRHE7DS93WY28DHGGEMC5B1W"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Create webhook subscription
requires authentication
Registers an HTTPS endpoint to receive event deliveries. We POST each
matching event to your URL with an HMAC-SHA256 signature in the
X-Emza-Signature: t=<unix>,v1=<hex> header. Compute the signature over
"{t}.{raw_body}" using the secret returned at creation.
The plain signing secret is returned ONCE — store it immediately.
Subsequent reads (index, show) omit the secret entirely. Use
PATCH /webhooks/{id} with rotate_secret=true to issue a fresh secret
(invalidates the previous one immediately).
URL validation (SSRF guard): the url field is rejected with 422
if it fails any of:
- Non-HTTPS scheme (
http://,ftp://,javascript:,file://) - Loopback IPs (
127.0.0.0/8,::1) - RFC 1918 private (
10/8,172.16/12,192.168/16) - Link-local + cloud metadata (
169.254.0.0/16,100.100.100.200) - IPv6 link-local (
fe80::/10) or unique-local (fc00::/7) - Hostname suffix
.local,.internal,.private,.lanorlocalhost - Hostname whose DNS resolves to any of the above
Allowed event names (set in events array — use ["*"] for all):
contract.created, contract.processing.completed, contract.processing.failed,
contract.signer.added, contract.signer.authenticated, contract.signer.signed,
contract.signer.rejected, contract.completed, contract.canceled,
contract.refunded, * (wildcard).
If * is present alongside specific events, the specifics are dropped
(the wildcard makes them redundant). Same event listed twice is deduped.
Scope: webhooks:manage. Idempotency-Key required (24h replay window).
Example request:
curl --request POST \
"http://localhost:8000/api/v1/webhooks" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"url\": \"https:\\/\\/example.com\\/webhooks\\/emza\",
\"events\": [
\"contract.completed\",
\"contract.signer.signed\"
],
\"active\": false
}"
const url = new URL(
"http://localhost:8000/api/v1/webhooks"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"url": "https:\/\/example.com\/webhooks\/emza",
"events": [
"contract.completed",
"contract.signer.signed"
],
"active": false
};
fetch(url, {
method: "POST",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (201):
{
"data": {
"id": "01krh7z0e8a4n39gf2ary73xbj",
"url": "https://example.com/webhooks/emza",
"events": [
"contract.completed",
"contract.signer.signed"
],
"active": true,
"disabled_at": null,
"failed_deliveries_count": 0,
"last_delivery_at": null,
"last_delivery_status": null,
"created_at": "2026-05-13T18:00:42.000Z",
"updated_at": "2026-05-13T18:00:42.000Z",
"secret": "35ada3b45aa00df901b74b2b483114315a0c21f7c096f17e0bb29ab7d907fe32",
"_warning": "Save this secret now — it will NOT be shown again."
}
}
Example response (422, SSRF blocked URL):
{
"type": "https://docs.elemza.com/errors/validation",
"status": 422,
"code": "validation",
"title_fa": "اعتبارسنجی ورودی شکست خورد",
"title_en": "Request validation failed",
"errors": {
"url": [
"آدرسهای داخلی شبکه (localhost / *.local / *.internal) مجاز نیستند."
]
},
"request_id": "01KRH8JRC4Y855P10CYC1C0AYS"
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Update webhook subscription
requires authentication
Partial update — any field omitted is left unchanged. New url values
pass through the same SSRF guard as POST /webhooks (see Create endpoint).
When rotate_secret=true, a fresh HMAC signing secret is generated and
returned in the response — the previous secret is invalidated immediately,
so any in-flight or already-sent webhook deliveries signed with the old
secret will fail verification on the receiver side. Plan rotations during
a quiet period or pause via active=false first.
Scope: webhooks:manage. Idempotency-Key required (24h replay window).
Example request:
curl --request PATCH \
"http://localhost:8000/api/v1/webhooks/01krh7z0e8a4n39gf2ary73xbj" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json" \
--data "{
\"url\": \"http:\\/\\/kunze.biz\\/iste-laborum-eius-est-dolor.html\",
\"events\": [
\"consequatur\"
],
\"active\": false,
\"rotate_secret\": false
}"
const url = new URL(
"http://localhost:8000/api/v1/webhooks/01krh7z0e8a4n39gf2ary73xbj"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
let body = {
"url": "http:\/\/kunze.biz\/iste-laborum-eius-est-dolor.html",
"events": [
"consequatur"
],
"active": false,
"rotate_secret": false
};
fetch(url, {
method: "PATCH",
headers,
body: JSON.stringify(body),
}).then(response => response.json());Example response (200):
{
"data": {
"id": "01krh7z0e8a4n39gf2ary73xbj",
"url": "https://example.com/webhooks/emza",
"events": [
"*"
],
"active": false,
"failed_deliveries_count": 2,
"last_delivery_at": "2026-05-13T17:30:00.000Z",
"last_delivery_status": "delivered"
}
}
Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Delete webhook subscription
requires authentication
Soft-deletes the subscription. In-flight deliveries that have already been queued will complete; no new deliveries are dispatched.
Example request:
curl --request DELETE \
"http://localhost:8000/api/v1/webhooks/AYbBkm7h8gnApxq66c0TVGHFAb" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/webhooks/AYbBkm7h8gnApxq66c0TVGHFAb"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "DELETE",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Send a synthetic test event
requires authentication
Fires a webhook.test event to verify the endpoint is reachable + signed
correctly. Counts toward webhook_deliveries like a real event.
Example request:
curl --request POST \
"http://localhost:8000/api/v1/webhooks/AYbBkm7h8gnApxq66c0TVGHFAb/test" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/webhooks/AYbBkm7h8gnApxq66c0TVGHFAb/test"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.
Replay webhook delivery
requires authentication
Creates a NEW delivery row with the same payload + subscription. The original row keeps its terminal status (failed / dead-letter / delivered).
Example request:
curl --request POST \
"http://localhost:8000/api/v1/webhook-deliveries/AYbBkm7h8gnApxq66c0TVGHFAb/replay" \
--header "Authorization: Bearer 42|abcdef1234567890abcdef1234567890abcdef" \
--header "Content-Type: application/json" \
--header "Accept: application/json"const url = new URL(
"http://localhost:8000/api/v1/webhook-deliveries/AYbBkm7h8gnApxq66c0TVGHFAb/replay"
);
const headers = {
"Authorization": "Bearer 42|abcdef1234567890abcdef1234567890abcdef",
"Content-Type": "application/json",
"Accept": "application/json",
};
fetch(url, {
method: "POST",
headers,
}).then(response => response.json());Received response:
Request failed with error:
Tip: Check that you're properly connected to the network.
If you're a maintainer of ths API, verify that your API is running and you've enabled CORS.
You can check the Dev Tools console for debugging information.