> ## Documentation Index
> Fetch the complete documentation index at: https://docs.didit.me/llms.txt
> Use this file to discover all available pages before exploring further.

# Programmatic Login

> Authenticate any verified email/password Didit account and receive an RS256 JWT pair (default lifetime 86400s). Console-created email/password accounts can use this endpoint; OAuth-only console accounts must set a password first.

export const AgentPromptAccordion = ({prompt, title = "AI Agent Integration Prompt"}) => {
  const [copied, setCopied] = React.useState(false);
  const handleCopy = e => {
    e.stopPropagation();
    if (!prompt) return;
    navigator.clipboard.writeText(prompt.trim()).then(() => {
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    });
  };
  const agents = ["Claude Code", "Codex", "Cursor", "Devin", "Windsurf", "GitHub Copilot"];
  return <div className="didit-agent-card">
      {}
      <div className="didit-agent-titlebar">
        <div className="didit-agent-dots" aria-hidden="true">
          <span className="didit-agent-dot didit-agent-dot-red"></span>
          <span className="didit-agent-dot didit-agent-dot-yellow"></span>
          <span className="didit-agent-dot didit-agent-dot-green"></span>
        </div>
        <span className="didit-agent-filename">{title}</span>
        <button type="button" className={`didit-agent-copy ${copied ? "didit-agent-copy-copied" : ""}`} onClick={handleCopy} title="Copy prompt to clipboard" aria-label={copied ? "Copied!" : "Copy prompt to clipboard"}>
          {copied ? <>
              <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
                <path d="M3 8.5l3.5 3.5L13 4" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
              </svg>
              <span>Copied</span>
            </> : <>
              <svg width="13" height="13" viewBox="0 0 16 16" fill="none">
                <rect x="5" y="5" width="9" height="9" rx="1.5" stroke="currentColor" strokeWidth="1.5" />
                <path d="M11 5V3.5A1.5 1.5 0 0 0 9.5 2h-6A1.5 1.5 0 0 0 2 3.5v6A1.5 1.5 0 0 0 3.5 11H5" stroke="currentColor" strokeWidth="1.5" />
              </svg>
              <span>Copy</span>
            </>}
        </button>
      </div>

      {}
      <pre className="didit-agent-body"><code>{prompt.trim()}</code></pre>

      {}
      <div className="didit-agent-footer">
        <span className="didit-agent-footer-label">Paste into</span>
        <div className="didit-agent-chips">
          {agents.map(name => <span key={name} className="didit-agent-chip">{name}</span>)}
        </div>
      </div>
    </div>;
};

<AgentPromptAccordion
  title="Programmatic Login Prompt"
  prompt={`Exchange a verified Didit account's email + password for a fresh JWT access token (headless, no browser).

Endpoint:
POST https://apx.didit.me/auth/v2/programmatic/login/

Authentication:
None. Email + password is the credential.

Request body:
- email (string, required) — Same email used at /programmatic/register/.
- password (string, required) — Plain password (HTTPS only).

Account requirements:
The account must have a verified email and password. Accounts created through POST /programmatic/register/ work immediately after email verification. Console-created email/password accounts can also use this endpoint. OAuth-only console accounts must set a password first.

curl example:
curl -X POST https://apx.didit.me/auth/v2/programmatic/login/ \\
-H "Content-Type: application/json" \\
-d '{
"email": "you@yourdomain.com",
"password": "MyStr0ng!Pass"
}'

Response (200 OK):
- access_token (string) — RS256 JWT. Pass as "Authorization: Bearer <access_token>" on every Account Management endpoint at apx.didit.me/auth/v2. DO NOT use against verification.didit.me — that host uses x-api-key.
- refresh_token (string) — RS256 JWT for refresh flows.
- expires_in (integer) — Default 86400 (24 hours).

Token shape:
- 24-hour default lifetime.
- RS256-signed.
- No browser session is required.

Brute-force protection:
- Progressive account lockout per email: 5 consecutive failures = 15 min lockout, 10 = 1 hour, 20 = 24 hours.
- Per-IP rate limit: 20 attempts/minute and 100 attempts/hour.

Failure modes:
- 400 — { "email": ["..."] } / { "password": ["..."] } field-level errors.
- 401 — { "detail": "Invalid email or password." }
- 403 — { "detail": "..." } when SSO policy blocks password login for the account.
- 429 — { "detail": "Too many login attempts.", "wait": <seconds> } on lockout or per-IP limit. Honour the wait value before retrying.

Recovering your api_key:
After a successful login, call GET /organizations/me/ to enumerate organizations and applications, then GET /organizations/me/{org_id}/applications/{app_id}/ to read the api_key — see /auth-api/get-credentials.

Where to use access_token vs api_key:
- access_token (Bearer) → https://apx.didit.me/auth/v2/... (org / app management).
- api_key (x-api-key) → https://verification.didit.me/v3/... (sessions, workflows, billing).

For end-to-end Didit integration, paste in the full prompt at /integration/integration-prompt.`}
/>


## OpenAPI

````yaml POST /programmatic/login/
openapi: 3.0.0
info:
  version: 1.0.0
  title: Didit Auth API
  description: >-
    Programmatic registration and authentication API for developers and AI
    agents. Register in 2 API calls -- no browser needed. The most
    agent-friendly identity verification platform.


    ## Base URL


    All endpoints in this spec are hosted at `https://apx.didit.me/auth/v2`.
    This is a different host than the verification API
    (`https://verification.didit.me/v3`). Keep them straight: this auth host
    issues your **JWT access tokens** and lets you list and manage your
    **organizations** and **applications**. The verification host is where you
    call sessions, workflows, AML, etc., authenticated with the **`api_key`**
    returned by this API.


    ## How the auth flow works


    1. `POST /programmatic/register/` — submit email + password.

    2. Receive a 6-character alphanumeric code by email.

    3. `POST /programmatic/verify-email/` — submit the code. You get back a JWT
    `access_token`, a `refresh_token`, an organization, and a default
    application with `client_id` + `api_key`.

    4. Save the `api_key`. Use it as `x-api-key` for every call to
    `https://verification.didit.me/v3/...`.

    5. From any future machine, `POST /programmatic/login/` exchanges your email
    + password for a fresh JWT, then call `GET /organizations/me/` and the
    application endpoints to recover or rotate credentials.


    The JWT is only needed for the **Account Management** endpoints in this
    spec; verification API traffic uses the long-lived `api_key`, not the JWT.


    ## Conventions


    - All request and response bodies are JSON (`Content-Type:
    application/json`).

    - **Use a real, deliverable email address when testing `POST
    /programmatic/register/`.** The endpoint sends the verification code
    synchronously, so addresses on reserved test domains (`@example.com`,
    `@example.org`, `@*.test`, `@*.example`, `@*.invalid`) are refused by mail
    delivery and the request returns a `500`. Substitute the
    `you@yourdomain.com` placeholder in the samples with an inbox you can
    actually read.

    - Validation errors return HTTP `400` with one of two envelopes: `{"field":
    ["message", ...]}` for field-level problems, or `{"detail": "message"}` for
    business-rule errors. A handful of validators (password strength on
    register, code expiry on verify-email) currently respond with a bare array
    `["message"]` instead of the field-keyed form -- this is a known server-side
    inconsistency, so client code should accept both shapes.

    - Authentication errors come back as `{"detail": "..."}` with HTTP `401`
    (invalid bearer token) or `403` (missing bearer token / insufficient
    privileges).

    - Rate-limit responses include a `wait` field with the cooldown in seconds.
servers:
  - url: https://apx.didit.me/auth/v2
    description: Production auth server
security: []
tags:
  - name: Programmatic Auth
    description: >-
      Email-and-password account lifecycle: register, verify, login. Returns the
      JWT `access_token` you use against the Account Management endpoints, plus
      (on verify) your first application's `client_id` and `api_key`.
  - name: Account Management
    description: >-
      Authenticated endpoints (JWT bearer) that list organizations and create,
      fetch, or update applications. Use these to recover or rotate the
      `api_key` you authenticate verification API calls with.
paths:
  /programmatic/login/:
    post:
      tags:
        - Programmatic Auth
      summary: Programmatic Login
      description: >-
        Authenticate any verified email/password Didit account and receive an
        RS256 JWT pair (default lifetime 86400s). Console-created email/password
        accounts can use this endpoint; OAuth-only console accounts must set a
        password first.
      operationId: programmatic_login
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/LoginRequest'
            examples:
              Default:
                summary: Programmatic login
                value:
                  email: you@yourdomain.com
                  password: MyStr0ng!Pass
      responses:
        '200':
          description: >-
            Login successful. Returns a fresh JWT pair. Use the `access_token`
            as `Authorization: Bearer <token>` against `/organizations/me/...`
            to look up the `org_id`, `app_id`, and `api_key`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/LoginResponse'
              examples:
                Success:
                  value:
                    access_token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
                    refresh_token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
                    expires_in: 86400
                    message: Login successful
        '400':
          description: Validation error. Field-level problems with the request body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ValidationError'
              examples:
                MissingFields:
                  summary: Empty body
                  value:
                    email:
                      - This field is required.
                    password:
                      - This field is required.
                InvalidEmail:
                  summary: Malformed email
                  value:
                    email:
                      - Enter a valid email address.
        '401':
          description: Authentication refused.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AuthError'
              examples:
                InvalidCredentials:
                  summary: >-
                    Email + password did not match (also returned when the
                    account does not exist, to avoid user-enumeration)
                  value:
                    detail: Invalid email or password.
                NotVerified:
                  summary: Account exists but email is not yet verified
                  value:
                    detail: Please verify your email address before logging in.
                SSORequired:
                  summary: The account belongs to an organization that requires SSO
                  value:
                    detail:
                      error: sso_required
                      message: Your organization 'Acme Corp' requires SSO login.
                      organization_name: Acme Corp
                      organization_slug: acme-corp
                      sso_url: /sso/acme-corp
        '429':
          description: >-
            Account locked (too many recent failures on this email) or IP
            rate-limited (too many attempts from this address).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RateLimitError'
              examples:
                AccountLocked:
                  summary: Progressive lockout tripped
                  value:
                    detail: >-
                      Account temporarily locked due to too many failed login
                      attempts. Please try again later.
                IPRateLimited:
                  summary: More than 20/min or 100/hour from this IP
                  value:
                    detail: >-
                      Too many login attempts from this IP address. Please wait
                      before trying again.
      x-codeSamples:
        - lang: curl
          label: cURL
          source: |-
            curl -X POST https://apx.didit.me/auth/v2/programmatic/login/ \
              -H "Content-Type: application/json" \
              -d '{
                "email": "you@yourdomain.com",
                "password": "MyStr0ng!Pass"
              }'
        - lang: python
          label: Python
          source: |-
            import requests

            resp = requests.post(
                "https://apx.didit.me/auth/v2/programmatic/login/",
                json={
                    "email": "you@yourdomain.com",
                    "password": "MyStr0ng!Pass",
                },
                timeout=10,
            )
            resp.raise_for_status()
            tokens = resp.json()
            access_token = tokens["access_token"]

            # Use against the Account Management endpoints on this host
            orgs = requests.get(
                "https://apx.didit.me/auth/v2/organizations/me/",
                headers={"Authorization": f"Bearer {access_token}"},
                timeout=10,
            ).json()
        - lang: javascript
          label: JavaScript
          source: |-
            const resp = await fetch(
              "https://apx.didit.me/auth/v2/programmatic/login/",
              {
                method: "POST",
                headers: { "Content-Type": "application/json" },
                body: JSON.stringify({
                  email: "you@yourdomain.com",
                  password: "MyStr0ng!Pass",
                }),
              },
            );
            if (!resp.ok) throw new Error(`Login failed: ${resp.status}`);
            const { access_token } = await resp.json();

            // Use against the Account Management endpoints on this host
            const orgs = await fetch(
              "https://apx.didit.me/auth/v2/organizations/me/",
              { headers: { Authorization: `Bearer ${access_token}` } },
            ).then((r) => r.json());
components:
  schemas:
    LoginRequest:
      type: object
      required:
        - email
        - password
      properties:
        email:
          type: string
          format: email
          example: you@yourdomain.com
        password:
          type: string
          example: MyStr0ng!Pass
    LoginResponse:
      type: object
      properties:
        access_token:
          type: string
          description: >-
            RS256-signed JWT. Use as `Authorization: Bearer <token>` for Account
            Management endpoints.
        refresh_token:
          type: string
        expires_in:
          type: integer
          description: Lifetime of the access token in seconds (default 86400).
          example: 86400
        message:
          type: string
          example: Login successful
    ValidationError:
      description: >-
        Validation error body. The intended envelope is a `{field: ["message",
        ...]}` map for field-level errors or a `{detail: "..."}` single-key
        envelope for business-rule errors. A handful of validators (password
        strength on register, code validity on verify-email) currently respond
        with a bare array `["message"]` -- known server-side inconsistency. The
        schema below keeps that bare-array branch so client code generated from
        this spec deserializes today's responses, but new client code should
        treat it as the relevant field-keyed form (`{password: [...]}` or
        `{code: [...]}`) and the server contract will converge on that shape.
      oneOf:
        - type: object
          additionalProperties:
            type: array
            items:
              type: string
          example:
            email:
              - Enter a valid email address.
        - type: object
          properties:
            detail:
              type: string
          example:
            detail: An account with this email already exists.
        - type: array
          items:
            type: string
          example:
            - Invalid or expired verification code.
    AuthError:
      type: object
      description: Authentication / authorization / not-found error envelope.
      properties:
        detail:
          type: string
          example: Invalid access token
    RateLimitError:
      type: object
      description: >-
        Rate-limit or progressive-lockout response. `wait` is the cooldown in
        seconds.
      properties:
        detail:
          type: string
          example: >-
            Too many login attempts from this IP address. Please wait before
            trying again.

````