# Amatya ID API

Amatya ID makes ID cards, event passes, tickets and membership cards, each with
a QR that anyone can verify and your own gate staff can scan. This page is how
you drive all of that from your own system.

Base URL: `https://siddhixsys.com/api/id/v1`

## Get a key

Create one in the app: **Amatya ID -> Developers -> API keys**. The key is shown
once. It carries three scopes — `id:read`, `id:write`, `id:scan` — and is
separate from your Amatya Tests key: neither works on the other product.

```bash
curl https://siddhixsys.com/api/id/v1/me -H "Authorization: Bearer aik_..."
```

`/me` answers with your plan, your caps and your current usage, so a script can
pace itself instead of discovering limits by being refused.

## Issue cards

A **project** is a batch of cards that share a design and a scan policy — one
event, one class, one gym roster. Pass `source_kind` + `source_ref` and the
call becomes idempotent: running your sync twice returns the same project.

```bash
curl -X POST https://siddhixsys.com/api/id/v1/projects   -H "Authorization: Bearer aik_..." -H "Content-Type: application/json"   -d '{"name":"Members 2026","format_key":"membership-gym",
       "scan_policy":"once_per_day","source_kind":"crm","source_ref":"members"}'
```

Then issue up to 500 cards per call:

```bash
curl -X POST https://siddhixsys.com/api/id/v1/projects/123/cards   -H "Authorization: Bearer aik_..." -H "Content-Type: application/json"   -H "Idempotency-Key: sync-2026-03-01"   -d '{"cards":[{"external_ref":"EMP-0001",
                 "fields":{"full_name":"Asha Kumari","member_id":"M1"},
                 "valid_until":"2026-12-31T18:30:00Z"}]}'
```

Three things worth knowing:

- **`external_ref` is yours.** A card posted with an `external_ref` that already
  exists is UPDATED, not duplicated — that is what makes a nightly sync safe.
  You can address any card later as `ref:EMP-0001` instead of storing our codes.
- **`Idempotency-Key` protects retries.** If your request times out, retry with
  the same key: you get the original response back, not 500 more cards. Reusing
  a key with a different body is refused (422) rather than silently replayed.
- **Every response carries `verify_url`** plus `qr_url`, a ready-made PNG. You do
  not wait on a render to print a ticket the moment a payment clears.

Partial failures come back as a per-item envelope with HTTP 207:

```json
{ "created": 98, "updated": 1, "failed": 1,
  "results": [{ "index": 0, "status": "created", "code": "8KJ2...",
                "external_ref": "EMP-0001",
                "verify_url": "https://siddhixsys.com/v/8KJ2...",
                "qr_url": "https://siddhixsys.com/api/id/v1/cards/8KJ2.../qr.png" }] }
```

Two semantics worth knowing before you build an updater: `"update"` replaces
the whole `fields` object (it is not a merge — send every key you want kept),
and `null` on any field means "leave unchanged", so a value cannot be cleared
over the API — revoke and reissue if a card was simply wrong.

## Photos

Photo ID cards need photo bytes. Upload them first, then reference the id:

```bash
curl -X POST https://siddhixsys.com/api/id/v1/assets   -H "Authorization: Bearer aik_..." -H "Content-Type: application/json"   -d '{"kind":"photo","data_uri":"data:image/jpeg;base64,..."}'
# -> { "asset_id": 41, "bytes": 18034, "mime": "image/jpeg" }
```

Then `"photo_asset_id": 41` on the card (and `"images": {"logo": 42}` for
image fields such as a crest). Uploads are re-encoded server-side — photos to
the exact 400x520 the card prints, logos to PNG — and identical bytes dedupe,
so re-sending the same photo on every sync costs one row, not a thousand.
An asset id you do not own fails that card with `unknown_asset` and touches
nothing else in the batch.

## Which fields does a design expect?

`GET /formats` lists every design you can point a project at, INCLUDING its
field schema — `key`, `label`, `type`, `required`, and `auto` (a field the
server assigns, like an auto-issued card number: leave it out of your payload).
Map your system's columns onto those keys once and the rest is a loop.

## Read, sync, revoke

| Call | What it does |
|---|---|
| `GET /projects/:id/cards?updated_since=&cursor=` | Changed cards only, cursor-paged. Add `&format=csv` for a spreadsheet. |
| `GET /cards/:code` or `GET /cards/ref:EMP-0001?project=123` | One card with its live state. |
| `GET /cards/:code/qr.png?size=512` | The printable QR. |
| `PATCH /cards/:code` | `{"action":"revoke"}`, `"restore"`, `"extend"` (with `days`), or `"update"` with `fields`. |
| `GET /cards/:code/scans` | Where and when that card was scanned. |
| `GET /verify/:code` | Read-only check. Records nothing. |
| `POST /scans` | Records a scan and returns the gate verdict. Needs `id:scan`. |

Renewing extends from the current expiry, so renewing a membership early never
shortens it.

Rate limits arrive as headers on every response
(`X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset`), with reads
and writes on separate budgets so a polling loop cannot starve a ticket sale.

## Webhooks

Point a URL at us in **Developers -> Webhooks** and we POST when something
happens: `card.created`, `card.updated`, `card.revoked`, `card.scanned`, and
`card.expiring_soon` (your horizon, default 7 days — the membership renewal
hook).

Every delivery carries `X-Amatya-Signature: t=<unix>,v1=<hmac>`. Verify it as
HMAC-SHA256 over `<t>.<raw body>` using your signing secret:

```js
const [t, v1] = header.split(',').map((p) => p.split('=')[1]);
const expected = crypto.createHmac('sha256', secret).update(t + '.' + rawBody).digest('hex');
const ok = crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
```

Compare the raw body, not a re-serialised object. Failed deliveries retry with
backoff and the webhook auto-disables after repeated failure — check the
delivery log on the Developers screen if events stop arriving.

`card.scanned` fires for refusals too. "Who was turned away" is usually the
number you actually wanted.

## Embeds

Two widgets, one script:

```html
<!-- status of one card, text only, links to the verification page -->
<script src="https://siddhixsys.com/embed-id.js" data-verify="CARDCODE" async></script>

<!-- live counts for one project, using a token pinned to your domains -->
<script src="https://siddhixsys.com/embed-id.js" data-stats="emb_..." async></script>
```

The verify widget deliberately shows **no tick and no badge**. A frame on someone
else's page can be styled to look like anything, so the authoritative answer
stays on `siddhixsys.com/v/<code>` where the address bar can be checked. The
stats tile only renders on the domains you listed when you created its token.

## AI agents (MCP)

Point any MCP client at `https://siddhixsys.com/api/id-mcp` with your key as the
Bearer token. Tools: `get_limits`, `list_formats`, `create_project`,
`issue_cards`, `get_card`, `update_card`, `verify_card`, `record_scan`,
`expiring_cards`.

> "Make ID cards for everyone in this spreadsheet, valid till March, and give me
> the print sheet."

## What the QR actually proves

A verified card's QR resolves to `/v/<code>`, which says the card was **issued by
the account that controls that URL** — no more. Only an account whose identity
we have checked out of band is presented as a confirmed organisation. Build your
own trust decisions on that distinction, not on the presence of a QR.

