# Adapter protocol

> The three signed endpoints your user database implements, with request and response formats and the signature scheme.

Source: https://9in.io/docs/reference/adapter-protocol/

The adapter protocol (version 2) is how 9in talks to [your own user database](https://9in.io/docs/own-database/). You implement three endpoints under a base URL; 9in calls them with signed JSON `POST` requests and expects signed responses. JSON Schemas: [user](https://9in.io/schemas/adapter-user.json), [/authenticate request](https://9in.io/schemas/adapter-authenticate-request.json), [/user request](https://9in.io/schemas/adapter-user-request.json), [event](https://9in.io/schemas/adapter-event.json).

## Requests

| Header | |
|---|---|
| `Content-Type` | `application/json` |
| `9in-signature` | `t=<unix seconds>,v1=<hex HMAC-SHA256>` |
| `9in-request-id` | A unique id for this request |

The signature covers `<t>.POST.<path>.<raw body>` with the shared secret from the console. Reject requests more than 5 minutes off, or with a wrong signature (compare in constant time).

## Responses

Sign every response with your own `9in-signature` header, computed the same way over `<t>.POST.<path>#<request id>.<response body>`, so a response can't be reused for another request. 9in rejects unsigned or wrongly signed responses. 9in waits up to the configured timeout (3 seconds by default).

## POST /authenticate

Check a password.

```json
{ "identifier": { "type": "email", "value": "jane@acme.com" }, "password": "…" }
```

`type` is `email`, `username` or `phone`.

| Status | Body |
|---|---|
| 200 | `{ "user": <user> }` |
| 401 | `{ "error": "invalid_credentials" }` (wrong password or unknown user: don't tell them apart) |
| 403 | `{ "error": "blocked" }` |

## POST /user

Look a user up.

```json
{ "id": "42" }
```

or

```json
{ "identifier": { "type": "phone", "value": "+447700900123" } }
```

| Status | Body |
|---|---|
| 200 | `{ "user": <user> }` |
| 404 | `{ "error": "not_found" }` |

## POST /event

```json
{ "id": "evt_…", "type": "otp.requested", "createdAt": "2026-11-09T08:00:00.000Z", "data": { … } }
```

Answer any 2xx (204 is fine).

| Type | `data` |
|---|---|
| `otp.requested` | `userId`, `channel` (`email` or `sms`), `to`, `code`, `purpose` (`login` or `password_reset`), `expiresAt` (ISO 8601) |
| `user.updated` | `userId`, `fields` |
| `user.password_changed` | `userId`, `fields` |

`userId` is your id. Passwords are never sent.

## The user object

| Field | Type | |
|---|---|---|
| `id` | string, required | Your user id (up to 256 characters) |
| `email` | string or null | |
| `email_verified` | boolean or null | |
| `phone_number` | string or null | E.164 |
| `phone_number_verified` | boolean or null | |
| `username` | string or null | |
| `name`, `given_name`, `family_name` | string or null | |
| `picture` | string or null | https URL |
| `locale`, `zoneinfo` | string or null | |
| `birthdate` | string or null | `YYYY-MM-DD` |
| `blocked` | boolean or null | `true` refuses sign-in |
| `metadata` | object or null | Stored on the 9in user |

Unknown fields are ignored.
