Issue and verify from your own systems
A JSON REST API with scoped API keys, plus signed webhooks that tell your systems what happened to each certificate. API access is included on every plan, including the free Starter plan.
Authentication
Create an API key in the app under Settings, then API keys. Choose the scopes it needs and, optionally, an expiry in days. The full key (it starts with crtfd_live_) is shown once, so store it safely. Send it as a bearer token:
Authorization: Bearer crtfd_live_your_key_here
All endpoints live under https://certifyd.cloud/api/v1 and speak JSON. Successful responses wrap the payload as { "data": ... }. Errors come back as { "error": { "code", "message" } }. Common codes are INVALID_API_KEY, INSUFFICIENT_SCOPE (the response names the missing scope) and CREDIT_LIMIT (HTTP 402, your monthly credits are used up). Requests are rate limited. Back off when you receive HTTP 429.
Scopes
| Scope | Grants |
|---|---|
| events:read, events:write | Read, or create / update / delete, events and their registration settings |
| attendees:read, attendees:write | Read, or create / update / delete / import, attendees |
| templates:read, templates:write | Read, or manage, certificate templates |
| certificates:generate | Issue, regenerate and bulk-generate certificates |
| certificates:read, certificates:write | Read and download certificates, or revoke, delete and send them |
| badges:read, badges:write | Read, or create / update / delete / issue, badges |
| analytics:read | Dashboard and per-event analytics |
| webhooks:manage | Manage webhook subscriptions and read their deliveries |
Example: issue a certificate, then verify it
The key needs the scopes templates:read, events:write, attendees:write and certificates:generate. Each issued certificate uses one credit. The examples use jq to pick values out of the JSON.
# 0. Your key and the API address
export CERTIFYD_KEY="crtfd_live_your_key_here"
export API="https://certifyd.cloud/api/v1"
# 1. Pick one of your templates (take the "id" of any entry in data)
curl -s "$API/templates?active=true" \
-H "Authorization: Bearer $CERTIFYD_KEY" | jq '.data[] | {id, name}'
export TEMPLATE_ID="paste-a-template-id-here"
# 2. Create an event
export EVENT_ID=$(curl -s -X POST "$API/events" \
-H "Authorization: Bearer $CERTIFYD_KEY" \
-H "Content-Type: application/json" \
-d "{\"name\":\"Safety Training\",\"eventDate\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\",\"templateId\":\"$TEMPLATE_ID\"}" \
| jq -r '.data.id')
# 3. Add an attendee
export ATTENDEE_ID=$(curl -s -X POST "$API/attendees" \
-H "Authorization: Bearer $CERTIFYD_KEY" \
-H "Content-Type: application/json" \
-d "{\"eventId\":\"$EVENT_ID\",\"firstName\":\"Ana\",\"lastName\":\"Rivera\",\"email\":\"ana@example.com\"}" \
| jq -r '.data.id')
# 4. Issue the certificate ("sendEmail": false holds back the delivery email).
# The Idempotency-Key makes a retry of this exact request safe (see below).
export CODE=$(curl -s -X POST "$API/certificates/generate" \
-H "Authorization: Bearer $CERTIFYD_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d "{\"templateId\":\"$TEMPLATE_ID\",\"eventId\":\"$EVENT_ID\",\"attendeeId\":\"$ATTENDEE_ID\",\"sendEmail\":false}" \
| jq -r '.data.verificationCode')
echo "Verification code: $CODE"
# 5. Verify it. This endpoint is public: no API key needed.
curl -s "$API/verify/$CODE"
The verify response includes status (valid, expired or revoked), the recipient, the credential, the issuer and the dates. The same code opens the human-readable page at https://certifyd.cloud/verify/<code>. See how to verify a credential.
Retries never issue twice
An attendee holds at most one valid certificate per event and template. If POST /certificates/generate names an attendee who already has one, the API answers HTTP 409 with the code ALREADY_ISSUED and a Location header pointing at the existing certificate; the error body's existing member carries its id, certificateNumber and verificationCode. Nothing is issued, emailed or charged. A revoked or expired certificate does not count, so you can issue a new one after it.
POST /badges/{id}/issue works the same way per attendee. An attendee who already holds the badge comes back in the response with "alreadyIssued": true, and only new badges use credits.
Both endpoints also accept an optional Idempotency-Key header: 1 to 255 printable characters, ideally a UUID that you generate per request. If the same key arrives again with the same body within 24 hours, the API answers HTTP 200 with the original response and the header Idempotent-Replayed: true. The same key with a different body is refused with HTTP 422 IDEMPOTENCY_KEY_REUSED. A repeat that arrives while the first request is still running gets HTTP 409 IDEMPOTENCY_IN_PROGRESS, so retry it a moment later. A request that failed is not remembered, so you can retry it with the same key.
Deleting an issued credential
DELETE /certificates/{id} on a certificate that has been issued revokes it with the reason "Deleted by issuer" (the public verify response also carries revocationReasonCode: "deleted_by_issuer") instead of erasing it, so anyone who scans its QR code sees "revoked", never "not found". Only a draft that was never issued is removed. DELETE /badges/{id} on a badge that has been issued retires it: it leaves your badge list and the badges already issued from it are revoked. An attendee who still has certificates cannot be deleted (HTTP 409 ATTENDEE_HAS_CERTIFICATES), and neither can one who has been issued badges (HTTP 409 ATTENDEE_HAS_BADGES).
Endpoints
Every path below is relative to /api/v1. The API reference lists every operation with its parameters, responses and the scope it needs, and lets you try a call.
| Area | Endpoints |
|---|---|
| Events | GET/POST /events GET/PUT/DELETE /events/{id} GET /events/{id}/attendees GET /events/{id}/certificates GET/PUT /events/{id}/registration |
| Bulk issuing | POST /events/{id}/certificates/generate POST /events/{id}/certificates/generate/jobs GET /events/{id}/certificates/generate/jobs/{jobId} |
| Attendees | GET/POST /attendees GET/PUT/DELETE /attendees/{id} GET /attendees/count POST /attendees/import GET /events/{id}/attendees/export |
| Templates | GET/POST /templates GET/PUT/DELETE /templates/{id} POST /templates/{id}/duplicate POST /templates/{id}/preview GET /system-templates |
| Certificates | POST /certificates/generate GET/DELETE /certificates/{id} GET /certificates/{id}/download POST /certificates/{id}/regenerate POST /certificates/{id}/revoke POST /certificates/{id}/send |
| Badges | GET/POST /badges GET/PUT/DELETE /badges/{id} POST /badges/{id}/issue GET /badges/{id}/assertions |
| POST /email/send-bulk GET /email/deliveries | |
| Analytics | GET /analytics/dashboard GET /analytics/events/{id} |
| Credits | GET /credits (credits used, limit and remaining this month) |
| Webhooks | GET/POST /webhooks PUT/DELETE /webhooks/{id} GET /webhooks/{id}/deliveries POST /webhooks/{id}/test |
| Verification (public) | GET /verify/{code} GET /verify/{code}/qr GET /verify/{code}/preview.png GET /verify/{code}/badge.png GET /verify/{code}/badge.svg GET /verify/{code}/certificate.pdf |
Webhooks
Subscribe an HTTPS endpoint with POST /webhooks (or in the app under Settings, then Webhooks), choosing which events it receives. Endpoints must use HTTPS and resolve to a public address. The signing secret is returned once, when the subscription is created.
| Event | Sent when |
|---|---|
| certificate.issued | A certificate is generated, one at a time or in bulk |
| certificate.sent | A certificate is emailed to its recipient |
| certificate.viewed | The recipient opens their portal link for the first time |
| certificate.downloaded | The recipient downloads the certificate PDF from the portal |
| certificate.revoked | You revoke a certificate |
You can also send yourself a sample with POST /webhooks/{id}/test, which delivers a webhook.test event.
What you receive
POST https://your-server.example/certifyd-hook
Content-Type: application/json
X-Certifyd-Event: certificate.issued
X-Certifyd-Delivery: 7c0e5b0e-0000-0000-0000-000000000000
X-Certifyd-Timestamp: 1790000000
X-Certifyd-Signature: sha256=3f1c...e9
{ "eventType": "certificate.issued", "timestamp": "...", "data": { "certificateId": "...", "certificateNumber": "...", "eventId": "...", "attendeeId": "...", "status": "...", "verificationCode": "CFD-7X4K-92QM" } }
The signature is a hex HMAC-SHA256 of "{timestamp}.{raw body}" keyed with your subscription secret. Verify it, reject timestamps that are more than a few minutes old, and use X-Certifyd-Delivery to ignore duplicates. It stays the same across retries.
# Recompute the signature on your side (bash + openssl) printf '%s.%s' "$TIMESTAMP" "$RAW_BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET"
Each delivery times out after 10 seconds. A failed delivery is tried up to 5 times in total, after 1 minute, 5 minutes, 30 minutes and 2 hours. A subscription that keeps failing is switched off after 10 failed attempts in a row.
What is not available
There are no official client libraries. The OpenAPI document is published at /openapi/v1.json, so you can generate a client in your own language. There is no native Zapier or Make connector either: any tool that can send HTTPS requests and receive webhooks can integrate with the endpoints above.
§In witness whereof
Issue your first certificate today
Set up takes about ten minutes. Free for 12 credentials a month, no card required.