# Certora REST API, MCP & Webhooks (v3.2)

Certora ships a JSON REST API and signed webhooks so your LMS, course platform or HR system
can issue certificates programmatically — and so anyone can verify a certificate by code
without an account.

**The batch endpoint is the point.** A cohort finishes a course, your LMS makes one call with
the whole roster, and every learner gets a certificate with its own verification code.

## Authentication

Create a key in **Settings → API & Webhooks**. Send it on every call:

    Authorization: Bearer apk_...

The key is shown once at creation and stored as a SHA-256 hash. Revoke any time.

**One endpoint is deliberately public:** `GET /api/verify/{code}` needs no key. A verification
code that only its issuer can check would be pointless — employers and registrars must be able
to verify. That endpoint reuses the public verify page's per-IP throttle (20 lookups / 60 s),
so it cannot be used to guess codes.

## Endpoints

| Method & path | What it does |
|---|---|
| `GET  /api/ping` | Verify a key |
| `GET  /api/templates` | Certificate templates with their tokens and validity period |
| `GET  /api/certificates` | Newest 200 issued certificates; `?recipient=` filters by name (substring) |
| `POST /api/certificates` | **Issue one certificate or a whole batch** |
| `GET  /api/verify/{code}` | **PUBLIC** — verify a code, no key required |
| `GET  /api/openapi.json` | OpenAPI 3.0 description of everything above |

### Issue one

    curl -X POST https://certs.example.com/api/certificates \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"template_id":1,"recipient":{"name":"Ada Lovelace","course":"Fire Safety"}}'

### Issue a batch (the LMS case)

    curl -X POST https://certs.example.com/api/certificates \
      -H "Authorization: Bearer apk_..." -H "Content-Type: application/json" \
      -d '{"template_id":1,"issue_date":"2026-06-30","recipients":[
            {"name":"Ada Lovelace","course":"Fire Safety"},
            {"name":"Grace Hopper","course":"Fire Safety"},
            {"name":"Alan Turing","course":"Fire Safety"}]}'

Returns `201` with every issued certificate, each carrying its own `code`, `verify_url` and
`pdf_url`. A batch is capped at 2000 recipients per call.

**Validation is all-or-nothing.** Every recipient is checked before anything is written, so a
bad row never leaves you with half a cohort certified and a `422` you cannot interpret.

Recipients are accepted inline — you do **not** have to create recipient records first. Your
LMS already owns the roster.

### Verify (public)

    curl https://certs.example.com/api/verify/ABCD-EFGH-JKMN

```json
{ "found": true, "valid": true, "status": "valid",
  "code": "ABCDEFGHJKMN", "display_code": "ABCD-EFGH-JKMN",
  "recipient_name": "Ada Lovelace", "course": "Fire Safety",
  "issuer": "Ridgeline Training", "issue_date": "2026-06-30", "expires_at": "2029-06-30" }
```

`status` is one of `valid`, `expired`, `revoked` — computed by the same engine the web verify
page uses. Unknown codes return `404` with `{"found": false}`. Too many lookups return `429`.

Since 3.2 a lookup here counts on the certificate exactly like one on the web page (the MCP
`verify_certificate` tool, your own agent, does not count). Every certificate object the API returns
carries `verify_count`, `last_verified_at` (UTC, or `null`) and `linkedin_clicks`.

## Public pages and badges (3.2)

No key and no sign-in; all on your own domain, built from **Settings → Public verify URL**.

| Method & path | What it does |
|---|---|
| `GET /verify/{code}/linkedin` | Counts an "Add to LinkedIn" press, then redirects to LinkedIn's Add-to-profile form filled in with the certificate (valid certificates only; otherwise `404`) |
| `GET /badges/issuer.json` | Open Badges 2.0 issuer profile (name, verify page, contact email) |
| `GET /badges/templates/{id}.json` | One badge class per template |
| `GET /badges/templates/{id}.svg` | The badge image |
| `GET /badges/{code}.json` | One assertion per certificate: hosted verification, the recipient's email hashed (`sha256$…` with its salt), `issuedOn`, `expires`. A revoked certificate answers `410` with `"revoked": true` |
| `GET /my` · `POST /my` | A person asks for a link to their certificates; it is emailed through your SMTP only when that address holds certificates, and the page answers the same either way |
| `GET /my/{token}` | Every certificate issued to that email address; the link works for 30 days |

Badges answer `404` until they are switched on in Settings with a contact email, and a certificate
issued without a recipient email has no badge.

## MCP (Model Context Protocol) — new in 3.0

Certora speaks MCP over stateless streamable HTTP at **`POST /mcp`**. Authentication is the same
revocable API key as the REST API (`Authorization: Bearer <key>`), and a key carries its owner's
role, so an agent can never do more than the person whose key it is holding.

`GET /mcp` answers **405** with `Allow: POST` — deliberately without the key check, so a probing
client is told "right endpoint, wrong verb" instead of a bare 401 that reads as "no MCP here".

```
POST /mcp   {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}
POST /mcp   {"jsonrpc":"2.0","id":2,"method":"tools/list"}
POST /mcp   {"jsonrpc":"2.0","id":3,"method":"tools/call",
             "params":{"name":"issue_certificates",
                       "arguments":{"template_id":1,"recipients":[4,5,6]}}}
```

| Tool | Writes? | What it does |
|---|---|---|
| `list_templates` | no | Templates with orientation, validity period and how many each has issued |
| `template_detail` | no | One template, its merge tokens, and the certificates issued from it |
| `list_certificates` | no | The register; filter by `template_id`, `status`, `search` |
| `issue_certificates` | **yes** | The batch operation — one certificate per recipient, each with its own code and a snapshotted expiry |
| `verify_certificate` | no | The same lookup the public `/verify` page performs |
| `expiring_report` | no | Expiring within N days, plus those already expired |

**Argument hygiene.** Tool arguments take the same scalar guard as the REST body: a value may not
be an array or object unless the tool allowlists it. `issue_certificates` allowlists exactly one —
`recipients` — because that is the list it exists to take. Unknown arguments are refused by name,
and enum values are refused with the list of what is accepted.

**One path, one answer.** `issue_certificates` calls the same writer the Generate screen calls, so
the merge, the code generation and the expiry snapshot cannot drift between surfaces. A certificate
issued by an agent verifies identically through `verify_certificate`, `GET /api/verify/{code}` and
the public page. `verify_certificate` returns the same shape this REST API already documents.

### Connect it to an assistant

Mint the key in the app first: **Settings → API keys**. Choose **Read only** when the assistant should
answer questions but never change anything — the endpoint then lists only the read tools and refuses the
rest by name, so a careless prompt cannot write. Full access behaves as before.

Every client needs the same three facts, and nothing in the handshake is vendor-specific:

| | |
|---|---|
| **The address** | `https://certs.example.com/mcp` |
| **The key** | header `Authorization: Bearer apk_xxxx` |
| **The transport** | MCP over streamable HTTP, stateless |

**Claude** — one command, or the same URL and header as a custom connector in the desktop and web apps:

```sh
claude mcp add --transport http certora https://certs.example.com/mcp \
  --header "Authorization: Bearer apk_xxxx"
```

**ChatGPT and the OpenAI API** — one entry in the Responses API's `tools` array (in ChatGPT itself, the
same URL and key go in as a connector):

```json
{
  "type": "mcp",
  "server_label": "certora",
  "server_url": "https://certs.example.com/mcp",
  "authorization": "apk_xxxx",
  "require_approval": "never"
}
```

**Every other client spells the same three facts differently — copy the shape from its own
documentation, not from here.** VS Code is the clearest example of why: its
[configuration reference](https://code.visualstudio.com/docs/agents/reference/mcp-configuration)
(read 6 September 2026) puts servers in `.vscode/mcp.json` under a **`"servers"` object** — *"an
object that maps server names to their configurations"*. Pasted as-is,
a block written for another client will not load there. The id, the URL and the token are what travel; the JSON around
them belongs to whichever client you are configuring.

**A local model, n8n, or your own code** — n8n's **MCP Client** node takes the URL and the same
`Authorization: Bearer` header; a model running on your own machine reaches it through any MCP client, so
nothing leaves your network at all. Writing it yourself is one POST of JSON-RPC 2.0:

```sh
curl -X POST https://certs.example.com/mcp \
  -H "Authorization: Bearer apk_xxxx" -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
```

Any client that speaks MCP's streamable-HTTP transport works, including ones that do not exist yet:
Certora implements the protocol, not an integration with a particular vendor.

## Webhooks

Add a receiver in **Settings → API & Webhooks** and choose events:

| Event | Fires when |
|---|---|
| `certificate.issued` | A certificate is issued — from **Generate**, the REST API or `/mcp` |
| `certificate.revoked` | A certificate is revoked — one at a time or in bulk from the register (once per certificate; revoking one already revoked sends nothing) |

**A batch of N recipients fires N separate `certificate.issued` events** — one per certificate,
each with its own code. That keeps a single event shape whether you issued one or a thousand.

Every delivery is signed:

    X-Certora-Event: certificate.issued
    X-Certora-Signature: sha256=<hmac_sha256(raw_body, your_webhook_secret)>

Compare with `hash_hmac('sha256', $rawBody, $secret)` and reject on mismatch. Deliveries and
their response codes are logged in the same settings page.

## Errors

| Code | Meaning |
|---|---|
| `401` | Missing, unknown or revoked key |
| `404` | No such certificate / code |
| `422` | Validation — the `detail` field names the offending field |
| `429` | Verification lookups throttled (public endpoint only) |


## Connect it to Zapier, Make or n8n

Certora talks to other tools without a plugin: it sends a webhook (an HTTPS message) the moment something happens, and it accepts calls to the API above. Zapier, Make and n8n can use both.

### Before you start

- In Certora, open **Settings → API & Webhooks**. Mint an API key (choose **Read only** if the other tool only reads), and add a webhook when you have its URL (below).
- Use your install's full address, for example `https://certora.yourcompany.com`.

### When something happens in Certora: send it to another tool

1. In the other tool, create the trigger that gives you a URL to receive webhooks:
   - **Zapier:** the "Webhooks by Zapier" app, trigger **Catch Hook** (Webhooks by Zapier needs a paid Zapier plan).
   - **Make:** module **Webhooks → Custom webhook**.
   - **n8n:** the **Webhook** node.
2. Copy that URL into Certora under **Settings → API & Webhooks**, and choose the event or events below.
3. Make the event happen once in Certora, so the other tool receives a sample and you can map its fields.

| Event | Fires when |
|---|---|
| `certificate.issued` | A certificate is issued — from Generate, the REST API or `/mcp` |
| `certificate.revoked` | A certificate is revoked — one at a time or in bulk from the register (once per certificate; revoking one already revoked sends nothing) |

Every delivery carries `X-Certora-Signature`, an HMAC-SHA256 signature over the raw body. Zapier and Make accept the delivery without checking it, so keep the webhook URL private. In n8n you can check it in a Code node before using the data.

### When something happens elsewhere: act in Certora

- **Zapier:** "Webhooks by Zapier", action **POST** (or **Custom Request**).
- **Make:** module **HTTP → Make a request**.
- **n8n:** the **HTTP Request** node.

Send the request to your install's address plus the path from the Endpoints table above, with the header `Authorization: Bearer <your API key>` and a JSON body. A read-only key cannot write.

The endpoints that change something:

- `POST /api/certificates`: **Issue one certificate or a whole batch**

### Example recipes

- **certificate.issued → a row in Google Sheets:** webhook trigger on `certificate.issued` (A certificate is issued — from Generate, the REST API or `/mcp`), then the Google Sheets "Create row" action with the fields you want.
- **certificate.revoked → a message in Slack or Teams:** webhook trigger on `certificate.revoked` (A certificate is revoked — one at a time or in bulk from the register (once per certificate; revoking one already revoked sends nothing)), then the Slack or Microsoft Teams "Send message" action.
- **A new form entry or row elsewhere → Certora:** the other tool's trigger (for example a new Google Sheets row or a form submission), then an HTTP action calling `POST /api/certificates` (**Issue one certificate or a whole batch**).

These recipes use only the events and endpoints documented above. We have not run them inside Zapier, Make or n8n ourselves; Certora's own automated tests check its API.
