Download OpenAPI specification:
Issue, verify, and manage certificates programmatically.
The CertSeal.com REST API lets you issue certificates and look them up from your own backend, LMS, or any other system. Every request is authenticated with a per-workspace API key, created from the API Keys page in the CertSeal.com web app.
Send your key in the Authorization header as a bearer token:
Authorization: Bearer csk_live_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
The key is bound to a single workspace; you don't need to pass a workspace id separately. Tokens are shown exactly once when you create them — store them in a secrets manager and rotate them by revoking + recreating.
All non-2xx responses use a single envelope:
{ "error": "QUOTA_EXCEEDED", "message": "...", "detail": { ... } }
The error code is stable and machine-readable; the message is
safe to surface to end users; the optional detail object carries
structured context (e.g. remaining quota for QUOTA_EXCEEDED).
Each API key is limited to 120 requests per minute by default.
Exceeding the limit returns 429 RATE_LIMITED with a Retry-After
header (in seconds). Standard RateLimit-* headers are returned on
every authenticated response so clients can self-throttle.
Issuing a certificate consumes one unit of your workspace owner's
plan quota. Lookup, list, and update endpoints are free. Plan limits
(e.g. maxBatches) also apply to API-driven actions exactly as
they do in the web app.
Instead of polling our list endpoints, register a
WebhookSubscription and CertSeal.com will POST signed JSON to your
URL when events happen in your workspace.
certificate.issued — a new recipient was created (POST /batches/{id}/recipients, the bulk variant, or a CSV import in the
web app). The payload's data is the standard Recipient shape.certificate.sent — the email worker successfully handed the
certificate to Resend. Payload is the same Recipient shape with
emailStatus = "sent".certificate.failed — Resend rejected the message. Payload adds an
error string describing the bounce.certificate.viewed — someone opened the public viewer URL for
the first time. Fires at most once per recipient. Payload adds
a viewer sub-object with userAgent, referer, ip, and
viewedAt.Subscribe and Zapier/Make-style unsubscribe both use the regular REST API:
# Create a subscription (returns the signing secret ONCE)
curl -X POST https://services.certseal.com/api/v1/webhooks/subscriptions \
-H "Authorization: Bearer csk_live_..." \
-H "Content-Type: application/json" \
-d '{ "url": "https://hooks.zapier.com/...", "events": ["certificate.issued"] }'
# Delete a subscription (also used by Zapier/Make on Zap-deleted)
curl -X DELETE https://services.certseal.com/api/v1/webhooks/subscriptions/{id} \
-H "Authorization: Bearer csk_live_..."
Every delivery carries an X-CertSeal-Signature header in the
Stripe-style t=<unixSeconds>,v1=<hexHmacSha256> format. The HMAC
is computed over ${t}.${rawRequestBody} using the subscription's
signing secret. Reject requests where |now - t| > 300 seconds to
defeat replay attacks.
Node.js verifier (paste into a Zapier code step or your own handler):
const crypto = require("node:crypto");
function verify(rawBody, signatureHeader, secret) {
const parts = Object.fromEntries(
signatureHeader.split(",").map((p) => p.split("=")),
);
const expected = crypto
.createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(expected, "hex"),
Buffer.from(parts.v1, "hex"),
);
const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
return ok && fresh;
}
X-CertSeal-Delivery (the
delivery id) as your idempotency key.failed.410 Gone is treated as the RESTHooks "unsubscribe" signal:
we hard-delete the subscription so you can re-subscribe the same
URL.suspended it. Re-activate from the Automations page in
the web app after fixing your endpoint.Test-authentication endpoint used by integrations (e.g. Zapier) to verify an API key and display a human-readable label for the connected workspace.
Verifies the bearer API key and returns a human-readable
username for the connected workspace. Intended for use as
Zapier's "Test Auth" / connection-label endpoint — a 2xx
response confirms the key is valid and the returned username
is safe to surface in the integration UI.
{- "workspaceId": "ws_abc123",
- "username": "Jane Doe"
}A batch is a group of recipients that share a single design (and optionally one email template). Create a batch first, then issue certificates into it.
Returns batches in the API key's workspace, newest first.
By default only active batches are returned. Use ?status=archived
to list batches that have been soft-deleted via the archive
endpoint, or ?status=all to merge both groups into one page.
| cursor | string Opaque cursor returned in the previous response's
|
| limit | integer [ 1 .. 100 ] Default: 25 Page size (clamped to 100). |
| status | string Default: "active" Enum: "active" "archived" "all" Filter by archive state. Defaults to |
{- "batches": [
- {
- "id": "string",
- "workspaceId": "string",
- "title": "string",
- "description": "string",
- "designId": "string",
- "emailTemplateId": "string",
- "courseName": "string",
- "issuerName": "string",
- "recipientCount": 0,
- "archivedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
], - "pagination": {
- "nextCursor": "string",
- "hasMore": true
}
}Creates a new batch in the API key's workspace. The designId
and (optional) emailTemplateId must already exist in the same
workspace; create them from the web app first.
| title required | string non-empty |
| description | string or null |
| designId required | string Must refer to a design in the same workspace as the API key. |
| emailTemplateId | string or null Must refer to an email template in the same workspace. |
| courseName | string or null |
| issuerName | string or null |
{- "title": "2026 Spring Cohort",
- "designId": "clx1abcd0000xxxxabcd"
}{- "batch": {
- "id": "string",
- "workspaceId": "string",
- "title": "string",
- "description": "string",
- "designId": "string",
- "emailTemplateId": "string",
- "courseName": "string",
- "issuerName": "string",
- "recipientCount": 0,
- "archivedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
}Returns the batch regardless of archive state — verification
tooling can keep resolving archived batches forever. Use the
archivedAt field on the response to detect frozen batches.
| id required | string Batch id. |
{- "batch": {
- "id": "string",
- "workspaceId": "string",
- "title": "string",
- "description": "string",
- "designId": "string",
- "emailTemplateId": "string",
- "courseName": "string",
- "issuerName": "string",
- "recipientCount": 0,
- "archivedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
}Soft-delete the batch. The row is preserved, every issued
certificate's public URL keeps resolving forever, and the rendered
PDF cache stays valid — but the batch is frozen: any future
recipient write (create / update / delete / send) returns
409 BATCH_ARCHIVED until you unarchive.
Idempotent: archiving an already-archived batch is a no-op that returns the current row.
Hard delete is intentionally not exposed by the API. See docs/api.md "Archiving batches" for the rationale.
| id required | string Batch id. |
{- "batch": {
- "id": "string",
- "workspaceId": "string",
- "title": "string",
- "description": "string",
- "designId": "string",
- "emailTemplateId": "string",
- "courseName": "string",
- "issuerName": "string",
- "recipientCount": 0,
- "archivedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
}Clear the batch's archivedAt timestamp, returning it to the
active list and re-enabling recipient writes / email sends.
Idempotent: unarchiving an active batch is a no-op that returns the current row.
| id required | string Batch id. |
{- "batch": {
- "id": "string",
- "workspaceId": "string",
- "title": "string",
- "description": "string",
- "designId": "string",
- "emailTemplateId": "string",
- "courseName": "string",
- "issuerName": "string",
- "recipientCount": 0,
- "archivedAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
}A design is a reusable certificate template authored in the
CertSeal editor. The API exposes designs read-only so integrations
can pick a designId when creating a batch; designs are created and
edited in the web app.
Returns designs in the API key's workspace, newest first. Read-only: designs are authored in the CertSeal web app. Use this to populate a design picker when creating a batch.
| cursor | string Opaque cursor returned in the previous response's
|
| limit | integer [ 1 .. 100 ] Default: 25 Page size (clamped to 100). |
{- "designs": [
- {
- "id": "string",
- "name": "string",
- "orientation": "string",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
], - "pagination": {
- "nextCursor": "string",
- "hasMore": true
}
}A recipient represents one issued certificate inside a batch.
Each recipient gets a certificateId (human code) and a
shareToken (used to build the public viewer URL).
| batchId required | string |
| cursor | string Opaque cursor returned in the previous response's
|
| limit | integer [ 1 .. 100 ] Default: 25 Page size (clamped to 100). |
| emailStatus | string Enum: "pending" "queued" "sending" "sent" "failed" Filter by current email state. |
{- "recipients": [
- {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}
], - "pagination": {
- "nextCursor": "string",
- "hasMore": true
}
}Creates one recipient (= one certificate) in the batch. A new
certificateId and shareToken are minted automatically unless
you supply your own. Set send: true to enqueue the
certificate email immediately — the batch must have an
emailTemplateId and the server must have Resend configured.
Consumes one unit of plan quota.
| batchId required | string |
| name | string or null |
string or null <email> | |
| certificateId | string or null Override the auto-generated certificate id. Must be unique across the whole system; duplicates return 409. |
| issueDate | string or null <date> ISO date (or full ISO timestamp). |
| expiryDate | string or null <date> |
object Free-form key/value bag interpolated into the certificate
template wherever | |
| send | boolean Default: false When |
{- "name": "Jane Doe",
- "email": "jane@example.com"
}{- "recipient": {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}
}Issues up to 100 recipients in a single request. The whole batch
is created in one database transaction — if any row fails (e.g.
a duplicate certificateId or quota exhaustion) none of them
are created and the response indicates which row caused the
failure.
| batchId required | string |
required | Array of objects (RecipientInput) [ 1 .. 100 ] items |
| send | boolean Default: false Apply to every recipient in the batch. |
{- "send": true,
- "recipients": [
- {
- "name": "Jane Doe",
- "email": "jane@example.com"
}, - {
- "name": "Jin Park",
- "email": "jin@example.com"
}, - {
- "name": "Sam Lee",
- "email": "sam@example.com",
- "data": {
- "score": "88%"
}
}
]
}{- "recipients": [
- {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}
], - "created": 0
}| batchId required | string |
| recipientId required | string |
{- "recipient": {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}
}Partial update — only the fields you include are changed. Pass
null to clear an optional field (e.g. "email": null).
| batchId required | string |
| recipientId required | string |
| name | string or null |
string or null <email> | |
| certificateId | string or null |
| issueDate | string or null <date> |
| expiryDate | string or null <date> |
object |
{- "name": "Jane S. Doe",
- "issueDate": "2026-05-01"
}{- "recipient": {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}
}Permanently deletes the recipient. Plan quota is not refunded — the underlying certificate id is already burned.
Note: this only works on active batches. If the recipient's batch
has been archived, you must unarchive it first; otherwise the
request fails with 409 BATCH_ARCHIVED.
| batchId required | string |
| recipientId required | string |
{- "ok": true
}Marks the recipient as queued so the background worker delivers
the email at the configured rate. The batch must already have an
emailTemplateId and the recipient must have an email.
| batchId required | string |
| recipientId required | string |
{- "recipient": {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}
}Authenticated certificate lookup — equivalent to the public viewer but workspace-scoped to your API key, and includes the recipient's email address.
Look up a certificate by its human-readable certificateId
(e.g. CERT-2026-003843-000001). Returns the recipient (with
email), the batch metadata, the design template JSON, and the
merged variable map — enough to render the cert client-side
without scraping the public viewer.
| certificateId required | string Example: CERT-2026-003843-000001 |
{- "recipient": {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}, - "batch": {
- "id": "string",
- "title": "string",
- "courseName": "string",
- "issuerName": "string"
}, - "design": {
- "id": "string",
- "name": "string",
- "templateJson": { }
}, - "variables": {
- "property1": "string",
- "property2": "string"
}
}Look up a certificate by its shareToken — the opaque value
used in public viewer URLs like /v/{shareToken}. Same response
shape as the certificate-id lookup.
| shareToken required | string >= 8 characters |
{- "recipient": {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}, - "batch": {
- "id": "string",
- "title": "string",
- "courseName": "string",
- "issuerName": "string"
}, - "design": {
- "id": "string",
- "name": "string",
- "templateJson": { }
}, - "variables": {
- "property1": "string",
- "property2": "string"
}
}Outbound webhooks deliver workspace events (certificate.issued,
certificate.sent, certificate.failed, certificate.viewed) to a
customer-supplied URL. Subscriptions are workspace-scoped via the
API key; payloads are signed with HMAC-SHA256 (see the "Outbound
webhooks" section in the introduction for verification and retry
semantics).
Returns every webhook subscription in the API key's workspace, newest first.
| cursor | string Opaque cursor returned in the previous response's
|
| limit | integer [ 1 .. 100 ] Default: 25 Page size (clamped to 100). |
{- "subscriptions": [
- {
- "id": "string",
- "events": [
- "certificate.issued"
], - "status": "active",
- "secretTail": "string",
- "failureCount": 0,
- "lastFiredAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
], - "pagination": {
- "nextCursor": "string",
- "hasMore": true
}
}Register a URL to receive POSTs when the specified events fire
in this workspace. The response includes a one-time
signingSecret — store it immediately, you cannot recover it
later. URL constraints:
https:// in production.| url required | string <uri> Endpoint that receives webhook POSTs. Must be |
| events required | Array of strings (WebhookEventType) non-empty Items Enum: "certificate.issued" "certificate.sent" "certificate.failed" "certificate.viewed" |
{- "events": [
- "certificate.issued",
- "certificate.sent"
]
}{- "subscription": {
- "id": "string",
- "events": [
- "certificate.issued"
], - "status": "active",
- "secretTail": "string",
- "failureCount": 0,
- "lastFiredAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}, - "signingSecret": "wh_sec_AbCdEfGh01234567890abcdefghijKLMN"
}{- "subscription": {
- "id": "string",
- "events": [
- "certificate.issued"
], - "status": "active",
- "secretTail": "string",
- "failureCount": 0,
- "lastFiredAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}
}Hard-deletes the subscription. Used by Zapier and Make as the
REST Hooks "unsubscribe" call; clients calling this directly
get the same effect. Returns 204 No Content on success.
| id required | string |
{- "error": "MISSING_API_KEY",
- "message": "Missing Authorization header"
}Enqueues a synthetic event delivery for the requested event type so consumers can verify their endpoint and pull a sample payload. The delivery goes through the normal dispatcher — signature, headers, and retry behavior are all identical to a real event.
If eventType is omitted, the first event the subscription
listens for is used. Returns 202 Accepted once the delivery
row is persisted; the outbound POST happens asynchronously.
| id required | string |
| eventType | string (WebhookEventType) Enum: "certificate.issued" "certificate.sent" "certificate.failed" "certificate.viewed" Stable identifier for an event a subscription can listen to.
Sent as the |
{- "eventType": "certificate.issued"
}{- "delivery": {
- "id": "string",
- "eventType": "certificate.issued",
- "createdAt": "2019-08-24T14:15:22Z"
}, - "sample": {
- "id": "whe_abcd1234EFGH56789ijklMNOP",
- "type": "certificate.issued",
- "createdAt": "2019-08-24T14:15:22Z",
- "data": {
- "id": "string",
- "batchId": "string",
- "name": "string",
- "email": "user@example.com",
- "certificateId": "CERT-2026-003843-000001",
- "shareToken": "string",
- "issueDate": "2019-08-24T14:15:22Z",
- "expiryDate": "2019-08-24T14:15:22Z",
- "data": {
- "property1": "string",
- "property2": "string"
}, - "emailStatus": "pending",
- "emailSentAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z"
}
}
}Generates a new HMAC signing secret for this subscription and returns it. Update your verifier immediately — events posted AFTER this call are signed with the new secret. The old secret is invalidated atomically.
| id required | string |
{- "subscription": {
- "id": "string",
- "events": [
- "certificate.issued"
], - "status": "active",
- "secretTail": "string",
- "failureCount": 0,
- "lastFiredAt": "2019-08-24T14:15:22Z",
- "createdAt": "2019-08-24T14:15:22Z",
- "updatedAt": "2019-08-24T14:15:22Z"
}, - "signingSecret": "string"
}