# Certificates API

Verify any certificate without a key, issue and revoke with a team ov_ key over REST, and verify from an agent with the MCP tool verify_credential.

URL: https://osintverse.com/docs/certificates/api

Three interfaces, all on `https://osintverse.com` (not the SearchIn API host):

| Interface               | Auth                                    | What it does                                                     |
| ----------------------- | --------------------------------------- | ---------------------------------------------------------------- |
| Public verification     | None                                    | Read a certificate's signed record, status, and how to check it. |
| REST issuing            | Team `ov_` key with Certificates access | Issue, list, read, and revoke certificates for the key's issuer. |
| MCP `verify_credential` | Any MCP connection                      | The public record, for agents.                                   |

## Public verification

```
GET /api/certificates/verify/{id}
GET /certificates/verify/{id}.json      (same response)
GET /certificates/verify/{id}.md        (Markdown twin)
GET /api/certificates/keys              (every signing key, past and present)
GET /api/certificates/{id}/openbadge.json  (Open Badges 3.0 export, eddsa-jcs-2022 proof)
GET /.well-known/did.json               (did:web document naming the export's signing keys)
```

The Open Badges export is a W3C Verifiable Credential built from the signed record on request; valid and expired certificates export, revoked and superseded ones answer `410`. Details and how to verify the proof are on [How verification works](https://osintverse.com/docs/certificates/verification#open-badges-30-export).

No account, no key. Rate-limited per IP; `429` carries `Retry-After`. The response never includes the recipient's email. An unknown id answers `404` with `{ "message", "code": "not_found" }`. Ids are normalised, so `ovc7k3qm8px2dla` and `OVC-7K3Q-M8PX-2DLA` are the same certificate; issuer-prefixed ids such as `OSINTCOL1-48213967` work the same way.

```bash title="Verify by id"
curl -s https://osintverse.com/api/certificates/verify/OVC-7K3Q-M8PX-2DLA | jq '{status, title, issuer, recipient, signature}'
```

The response shape and the verification steps are on [How verification works](https://osintverse.com/docs/certificates/verification). Pages that the recipient has not opted into indexing carry `X-Robots-Tag: noindex`.

## REST issuing

### Authentication

Create a **team** API key on the team's API keys page with access level **Certificates** (issue, read, and revoke certificates; quotes and providers readable; never charges) or **Full access**. Personal keys cannot issue: certificates belong to a team's issuer. The key's creator must still be a member whose role can issue (owner, admin, member, or analyst).

Send the key as a bearer token or in `x-api-key`:

```
Authorization: Bearer ov_your_key
x-api-key: ov_your_key
```

Every call counts once toward the key's daily request limit. Errors are `{ "message": "…", "code": "…" }`:

| Status | Code                                            | Meaning                                                                              |
| ------ | ----------------------------------------------- | ------------------------------------------------------------------------------------ |
| `401`  | `invalid_key`                                   | Missing, unknown, expired, or revoked key.                                           |
| `403`  | `team_key_required`                             | A personal key. Create the key on the team's API keys page.                          |
| `403`  | `capability_missing`                            | The key's access level does not include certificates.                                |
| `403`  | `forbidden`                                     | The key creator's team role cannot issue.                                            |
| `403`  | `issuer_blocked`, `batch_limit`, `monthly_cap`  | Trust-level limits (see [Issuing](https://osintverse.com/docs/certificates/issuing)).                      |
| `404`  | `no_issuer`                                     | The key's team has no issuer yet. Create one in the issuer console.                  |
| `404`  | `not_found`                                     | Unknown type or certificate for this issuer.                                         |
| `409`  | `all_duplicates`, `type_archived`, `bad_status` | Nothing to do, or the state does not allow the action.                               |
| `429`  | `rate_limited`                                  | The key's daily request limit. `Retry-After` is set.                                 |
| `503`  | `certificates_disabled`, `signing_unavailable`  | Issuing is paused, or the signing key is not configured. Verification keeps working. |

### POST /api/certificates/issue

Issues one certificate or a batch from a type you own. Same validation and caps as the console; the batch source is recorded as `api`.

| Field              | Type       | Required                           | Notes                                                                                                                                   |
| ------------------ | ---------- | ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| `type`             | `string`   | Yes                                | The credential type's slug or id.                                                                                                       |
| `recipients`       | `object[]` | One of `recipients` or `recipient` | Batch. The size limit depends on the issuer's trust level.                                                                              |
| `recipient`        | `object`   |                                    | A single recipient (same shape as a `recipients` item).                                                                                 |
| `personal_message` | `string`   | No                                 | A line for every email in the batch; a recipient's `message` overrides it.                                                              |
| `allow_duplicates` | `boolean`  | No                                 | Issue even to recipients who already hold a valid certificate of this type. Default `false`: they are skipped and counted in `skipped`. |

Each recipient:

| Field        | Type     | Required | Notes                                                                                |
| ------------ | -------- | -------- | ------------------------------------------------------------------------------------ |
| `name`       | `string` | Yes      |                                                                                      |
| `email`      | `string` | Yes      | Lower-cased.                                                                         |
| `handle`     | `string` | No       | Without the `@`. Needed for handle display modes.                                    |
| `issued_at`  | `string` | No       | `YYYY-MM-DD`; default today.                                                         |
| `expires_at` | `string` | No       | `YYYY-MM-DD`, after `issued_at`; default from the type.                              |
| `fields`     | `object` | Per type | `{ "score": "92" }`; values are strings and are validated by the type's field kinds. |
| `message`    | `string` | No       | Personal line for this recipient's email.                                            |

```bash title="Issue one certificate"
curl -X POST https://osintverse.com/api/certificates/issue \
  -H "Authorization: Bearer ov_your_key" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "osint-fundamentals-completion",
    "recipient": { "name": "Jane Doe", "email": "jane@example.com", "handle": "janedoe", "fields": { "score": "92" } },
    "personal_message": "Great work on the final exercise."
  }'
```

```json title="201 Created"
{
  "batch_id": "6f1c…",
  "source": "api",
  "issued": 1,
  "skipped": 0,
  "certificates": [
    {
      "id": "OVC-7K3Q-M8PX-2DLA",
      "recipient_name": "Jane Doe",
      "recipient_email": "jane@example.com",
      "verify_url": "https://osintverse.com/certificates/verify/OVC-7K3Q-M8PX-2DLA",
      "json_url": "https://osintverse.com/certificates/verify/OVC-7K3Q-M8PX-2DLA.json"
    }
  ]
}
```

Rows that fail validation fail the whole call with `400` and `code: invalid_rows`; the message names the first few problems. Emails go out after the response; read each certificate's `email.status` later.

> **No idempotency key yet.** A retried call issues again unless the recipients already hold the certificate (the default duplicate check). Keep `allow_duplicates` off for retries.

### GET /api/certificates/issued

Lists the issuer's certificates, newest first, with keyset paging.

| Query    | Notes                                     |
| -------- | ----------------------------------------- |
| `q`      | Matches name, email, handle, or id.       |
| `type`   | Type slug or id.                          |
| `status` | `valid`, `revoked`, or `superseded`.      |
| `cursor` | `next_cursor` from the previous page.     |
| `limit`  | Rows per page, up to the list page limit. |

```json title="200 OK"
{ "items": [ { "id": "OVC-…", "…": "…" } ], "next_cursor": "…" }
```

### GET /api/certificates/issued/:id

One certificate with its event log (`:id` is the certificate id, for example `OVC-7K3Q-M8PX-2DLA`).

```json title="200 OK"
{
  "certificate": {
    "id": "OVC-7K3Q-M8PX-2DLA",
    "type": { "id": "…", "title": "OSINT Fundamentals — Completion" },
    "batch_id": "6f1c…",
    "recipient": { "name": "Jane Doe", "email": "jane@example.com", "handle": "janedoe" },
    "display_mode": "name",
    "listed": false,
    "indexable": false,
    "fields": { "score": "92" },
    "personal_message": "Great work on the final exercise.",
    "issued_at": "2026-10-31T00:00:00.000Z",
    "expires_at": null,
    "status": "valid",
    "public_status": "valid",
    "revocation": null,
    "superseded_by": null,
    "supersedes": null,
    "claimed_at": null,
    "email": { "status": "sent", "sent_at": "2026-10-31T09:12:04.000Z", "error": null },
    "view_count": 3,
    "created_at": "2026-10-31T09:12:01.000Z",
    "urls": { "html": "…", "json": "…", "pdf": "…", "png": "…" }
  },
  "events": [ { "kind": "emailed", "at": "…", "meta": { "status": "sent" } }, { "kind": "issued", "at": "…", "meta": { "source": "api" } } ]
}
```

The recipient's email and the private fields (`display_mode`, `listed`, `indexable`, the private revocation reason) are visible to the issuer here and never on the public endpoint.

### POST /api/certificates/issued/:id/revoke

```json title="Request"
{ "reason": "Issued to the wrong cohort", "public": true }
```

Returns `{ "certificate": … }` with `status: "revoked"`. Only a valid certificate can be revoked; the action cannot be undone. Reissue after a typo from the console instead.

## MCP: verify\_credential

The OSINTverse MCP server has a read-only tool for agents:

```json
{ "name": "verify_credential", "arguments": { "credential_id": "OVC-7K3Q-M8PX-2DLA" } }
```

It returns the same JSON as the public verification endpoint: status, title, issuer and whether OSINTverse verified it, the recipient as disclosed, the signed record, the hash and signature, the keys URL, and the steps to check them. It needs the `osint:read` scope, never charges, and is available in sandbox and live mode. Agents are told that the issuer's and recipient's details are the issuer's statements, signed by OSINTverse at issue time, and that the signature does not vouch for the issuer unless the issuer is marked verified.

- [Get an API key](https://osintverse.com/dashboard/api)
- [OpenAPI spec](https://osintverse.com/openapi.json) and [Swagger UI](https://apiv1.osintverse.com/docs)
- [API sandbox (free, mock data)](https://osintverse.com/developers/sandbox)
- [Pricing](https://osintverse.com/pricing.json)
