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

# Finish a social sign-in

> Spends the state, exchanges the code with the provider, and resolves the identity onto a contact. The response is the hosted login's, so an SDK has one shape to handle however somebody signed in.

**How the account is resolved**, in order:

1. A contact already linked to this provider account — resolved by the provider's subject id, never by the email, so a changed address is still the same person.
2. The provider **vouched** for an address: it links onto the contact that owns it, or creates one. If that contact's address had never been proven, the password on it had never been proven either — it is removed, every session and outstanding one-time token with it, and the inbox is told. That is the takeover closed: signing up with somebody else's address and waiting.
3. The provider did **not** vouch: the provider account is the only identity the sign-in carries. The email lands as an attribute and resolves to nothing.

The state is single use, bound to its provider and its environment.

Rate limited to 30 requests per hour per IP.



## OpenAPI

````yaml /api-reference/openapi.json post /v1/contact-auth/oauth/{provider}/callback
openapi: 3.1.0
info:
  title: UserKit API
  version: 1.0.0
  description: >-
    The HTTP surface of UserKit.


    Two planes share one API. The **staff plane** is what a human uses in the
    panel: users, organizations, roles, members, keys. The **customer plane** is
    what a developer's own product uses: contacts, identities, hosted or
    federated sign-in.


    Every error answers the same envelope — `{"error": {"code", "message"}}`.
    The `code` is a stable contract to branch on; the `message` is for a person
    and may change.
servers:
  - url: '{baseUrl}'
    description: The API host.
    variables:
      baseUrl:
        default: https://api.userkit.dev
        description: Base URL of the API, no trailing slash.
security:
  - sessionToken: []
tags:
  - name: Authentication
    description: >-
      Public sign-up, sign-in, two-factor and password recovery for staff
      accounts.
  - name: Session
    description: >-
      The active session: who the caller is, which organization they are in, and
      signing out.
  - name: Account
    description: The caller's own account — profile, password, sessions, avatar.
  - name: Two-factor
    description: >-
      TOTP setup, activation and recovery codes. Returns 501 when two-factor is
      unavailable on the server.
  - name: Organizations
    description: The organizations a user belongs to, and the active one.
  - name: Environments
    description: >-
      The live and test environments seeded with every organization, and their
      identity settings.
  - name: Members
    description: Memberships and invitations.
  - name: Roles
    description: Roles and the permission catalogue they draw from.
  - name: API keys
    description: Secret keys (`uk_sk_…`) and publishable keys (`uk_pk_…`).
  - name: Contacts (panel)
    description: >-
      The staff view of the customer plane. Reads take `?environment=` as an
      explicit view parameter.
  - name: Contacts (server)
    description: >-
      The machine surface, authenticated by an API key. The environment is the
      key's environment and cannot be named by the caller.
  - name: Customer plane
    description: >-
      Called from the developer's own pages with a publishable key: boot, magic
      link, and the contact's own session.
  - name: Hosted auth
    description: >-
      Sign-up, sign-in, verification and recovery for contacts, when UserKit
      owns the account.
  - name: System
    description: Health and the internal job callback.
paths:
  /v1/contact-auth/oauth/{provider}/callback:
    post:
      tags:
        - Hosted auth
      summary: Finish a social sign-in
      description: >-
        Spends the state, exchanges the code with the provider, and resolves the
        identity onto a contact. The response is the hosted login's, so an SDK
        has one shape to handle however somebody signed in.


        **How the account is resolved**, in order:


        1. A contact already linked to this provider account — resolved by the
        provider's subject id, never by the email, so a changed address is still
        the same person.

        2. The provider **vouched** for an address: it links onto the contact
        that owns it, or creates one. If that contact's address had never been
        proven, the password on it had never been proven either — it is removed,
        every session and outstanding one-time token with it, and the inbox is
        told. That is the takeover closed: signing up with somebody else's
        address and waiting.

        3. The provider did **not** vouch: the provider account is the only
        identity the sign-in carries. The email lands as an attribute and
        resolves to nothing.


        The state is single use, bound to its provider and its environment.


        Rate limited to 30 requests per hour per IP.
      operationId: callbackContactOAuth
      parameters:
        - $ref: '#/components/parameters/OAuthProvider'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - publishable_key
                - state
                - code
              properties:
                publishable_key:
                  type: string
                state:
                  type: string
                  description: The `state` the start call returned.
                code:
                  type: string
                  description: The authorization code the provider put on the redirect.
      responses:
        '200':
          description: Signed in.
          content:
            application/json:
              schema:
                type: object
                properties:
                  contact:
                    $ref: '#/components/schemas/Contact'
                  token:
                    type: string
                  expires_at:
                    type: string
                    format: date-time
                  verified:
                    type: boolean
        '401':
          description: >-
            `invalid_grant` — the state is unknown, expired or already spent, or
            the provider refused the code.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '403':
          $ref: '#/components/responses/OriginNotAllowed'
        '404':
          description: '`unknown_provider`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '409':
          description: '`environment_not_hosted`, or `oauth_provider_not_configured`.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '429':
          $ref: '#/components/responses/RateLimited'
        '502':
          description: '`provider_unavailable` — the identity provider could not be reached.'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
      security: []
components:
  parameters:
    OAuthProvider:
      name: provider
      in: path
      required: true
      description: >-
        The social provider. `google` today; adding one is a row and an
        implementation, never a migration.
      schema:
        type: string
        enum:
          - google
  schemas:
    Contact:
      type: object
      description: One of the developer's own users — anonymous or identified.
      properties:
        id:
          type: string
          format: uuid
        name:
          type:
            - string
            - 'null'
        email:
          type:
            - string
            - 'null'
        avatar_url:
          type:
            - string
            - 'null'
        identified:
          type: boolean
          description: Whether this contact is more than a visitor.
        email_verified:
          type: boolean
          description: >-
            Whether the ADDRESS was proven — by a verification link, a reset
            link or a magic link. Distinct from a session's `verified` flag.
        attribution:
          $ref: '#/components/schemas/Attribution'
        first_seen_at:
          type: string
          format: date-time
        last_seen_at:
          type: string
          format: date-time
        created_at:
          type: string
          format: date-time
    Error:
      type: object
      description: Every error in this API answers this envelope.
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              description: Stable. Branch on this.
            message:
              type: string
              description: For a person. May change.
          required:
            - code
            - message
      required:
        - error
      example:
        error:
          code: forbidden
          message: your role does not allow this action
    Attribution:
      type: object
      description: >-
        First touch, captured on the visitor at first load and preserved through
        a merge. All fields optional; empty means unknown.
      properties:
        utm_source:
          type:
            - string
            - 'null'
        utm_medium:
          type:
            - string
            - 'null'
        utm_campaign:
          type:
            - string
            - 'null'
        utm_term:
          type:
            - string
            - 'null'
        utm_content:
          type:
            - string
            - 'null'
        referrer:
          type:
            - string
            - 'null'
        landing_page:
          type:
            - string
            - 'null'
  responses:
    OriginNotAllowed:
      description: >-
        `origin_not_allowed` — this publishable key does not accept calls from
        this origin.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
    RateLimited:
      description: >-
        `rate_limited` — too many requests. `Retry-After` carries the window in
        seconds.


        The counters are shared across instances. When that store cannot be
        reached each instance counts on its own instead: the limits get looser,
        never absent.
      headers:
        Retry-After:
          schema:
            type: integer
          description: Seconds until the window resets.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
  securitySchemes:
    sessionToken:
      type: http
      scheme: bearer
      description: >-
        A staff session token, `uk_st_…`. Minted by sign-up, sign-in or the
        two-factor exchange.

````