Skip to content
einvoicing.dev

API/Sign-in

Email a one-time sign-in code

POST/v1/sign-ins· no key needed

Starts a sign-in by emailing a six-digit code to the address given. Confirm it with POST /v1/sign-ins/{sign_in_id}/confirmation to get a key. This creates the account if the address is new, and adds a key to the account if it is not.

The response is identical whether or not an account exists. Anything else would let this endpoint answer "is this person a customer?" for any email address.

Every request spends a solved challenge from POST /v1/sign-in-challenges, passed as altcha. After that:

  • One code per address per minute. Asking again sooner is 429 with Retry-After.
  • One open sign-in per address. A new code cancels the previous one, which then answers 410. Holding several codes open would otherwise multiply the attempts.
  • A code expires ten minutes after it is sent, can be used once, and allows five attempts.
  • Requests are also rate-limited per email address and per IP.
  • A global cap on sign-in emails per hour answers 503 when reached.

The email says plainly that we never ask for the code, and shows when and from where the request was made, so its owner can tell whether it was theirs. This is the only way into the API that requires no key.

Request
curl -X POST https://api.einvoicing.dev/v1/sign-ins \  -H "Content-Type: application/json" \  -d '{"email":"[email protected]","altcha":"<altcha>"}'

Request body

application/json· required

FieldTypeDescription
emailrequiredstring · emailWhere to send the code. This is the account's identity; there is no username.
altcharequiredstringA challenge from POST /v1/sign-in-challenges, solved: the base64 of {"challenge": <data>, "solution": {"counter": <n>, "derivedKey": "<hex>"}}. This is exactly what ALTCHA's solvers produce.

Response

202

A code has been sent, if the address can receive one. Keep id: it is the only thing the code will work with.

FieldTypeDescription
datarequiredSignInA pending sign-in. Its code arrives by email.
Example responseapplication/json
{  "data": {    "id": "01J9Z3K4Q7VN3XW2R5T6Y8B0CD",    "email": "[email protected]",    "expires_at": "2026-09-11T14:13:11.482Z"  }}

Errors

Every error is application/problem+json (RFC 9457). Branch on type, which is stable, never on title or detail.

StatusWhen
400

The body could not be parsed as the XML or JSON its Content-Type declares. Distinct from 422, which means the body parsed and its contents were rejected. Problem type is malformed-body.

/problems/malformed-body

422

Either the challenge was not accepted (invalid-challenge: missing, malformed, unsolved, forged, expired or already spent), or the body did not match this schema (invalid-request). For invalid-challenge, fetch and solve a new challenge.

429

A code was sent to this address in the last minute, or a per-address or per-IP limit was reached. Wait for the number of seconds in Retry-After. Problem type is rate-limited.

/problems/rate-limited

503

The global cap on sign-in emails per hour has been reached, so new sign-ins are paused until volume falls. It is not a fault in the request, and existing keys are unaffected. Problem type is sign-ins-unavailable.

Schemas

SignInRequest

The address to send a one-time code to, and a solved challenge.

FieldTypeDescription
emailrequiredstring · emailWhere to send the code. This is the account's identity; there is no username.
altcharequiredstringA challenge from POST /v1/sign-in-challenges, solved: the base64 of {"challenge": <data>, "solution": {"counter": <n>, "derivedKey": "<hex>"}}. This is exactly what ALTCHA's solvers produce.

SignIn

A pending sign-in. Its code arrives by email.

FieldTypeDescription
idrequiredUlidA ULID. Opaque and time-ordered.
emailrequiredstring · emailThe address the code was sent to, trimmed and lower-cased. This is the form the account is identified by.
expires_atrequiredTimestamp · date-timeRFC 3339, UTC, millisecond precision.