openapi: 3.1.0
info:
  title: TextRail SMS API
  version: 1.0.0
  description: "Send SMS and OTP codes over HTTPS. API key scopes: sms:send, sms:read, otp:send, otp:verify."
servers:
  - url: https://api.textrail.dev
paths:
  /v1/sms:
    post:
      operationId: sendSms
      summary: "Queue one SMS. Reserves the price on accept. A repeat with the same Idempotency-Key and
        body returns the first response, with header Idempotent-Replayed: true. When sending is
        unavailable, this still answers 202 with sendingAvailable false and a notice: the message
        waits, and if it expires unsent it is not charged."
      x-scope: sms:send
      security:
        - apiKey: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: Client-chosen, 1 to 128 characters, unique per logical send.
          schema:
            type: string
            minLength: 1
            maxLength: 128
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/SendSmsRequest"
      responses:
        "202":
          description: Success
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SendSmsResponse"
        "400":
          description: IDEMPOTENCY_KEY_REQUIRED, VALIDATION_FAILED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: UNAUTHENTICATED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "402":
          description: INSUFFICIENT_BALANCE
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: ACCOUNT_NOT_VERIFIED, ACCOUNT_SUSPENDED, COUNTRY_NOT_ALLOWED, SCOPE_MISSING
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: IDEMPOTENCY_CONFLICT
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: INVALID_PHONE_NUMBER, MESSAGE_TOO_LONG
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: QUOTA_EXCEEDED, RATE_LIMITED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: SERVICE_PAUSED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /v1/sms/{id}:
    get:
      operationId: getSms
      summary: State of one SMS sent from this account. The number comes back masked.
      x-scope: sms:read
      security:
        - apiKey: []
      parameters:
        - name: id
          in: path
          required: true
          schema:
            type: string
            minLength: 1
            maxLength: 64
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SmsStatusResponse"
        "401":
          description: UNAUTHENTICATED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: ACCOUNT_NOT_VERIFIED, ACCOUNT_SUSPENDED, SCOPE_MISSING
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "404":
          description: NOT_FOUND
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: RATE_LIMITED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /v1/otp/send:
    post:
      operationId: otpSend
      summary: Generate and text a 6 digit code, valid 5 minutes. One active challenge per key, recipient
        and purpose; 60 second resend cooldown.
      x-scope: otp:send
      security:
        - apiKey: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: Client-chosen, 1 to 128 characters, unique per logical send.
          schema:
            type: string
            minLength: 1
            maxLength: 128
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OtpSendRequest"
      responses:
        "202":
          description: Success
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OtpSendResponse"
        "400":
          description: IDEMPOTENCY_KEY_REQUIRED, VALIDATION_FAILED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: UNAUTHENTICATED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "402":
          description: INSUFFICIENT_BALANCE
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: ACCOUNT_NOT_VERIFIED, ACCOUNT_SUSPENDED, COUNTRY_NOT_ALLOWED, SCOPE_MISSING
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "409":
          description: IDEMPOTENCY_CONFLICT
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "422":
          description: INVALID_PHONE_NUMBER
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: OTP_COOLDOWN, QUOTA_EXCEEDED, RATE_LIMITED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "503":
          description: SERVICE_PAUSED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
  /v1/otp/verify:
    post:
      operationId: otpVerify
      summary: "Check a code: atomic, one use, 5 attempts. Every failure looks the same: 200 with verified
        false."
      x-scope: otp:verify
      security:
        - apiKey: []
      parameters: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/OtpVerifyRequest"
      responses:
        "200":
          description: Success
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/OtpVerifyResponse"
        "400":
          description: VALIDATION_FAILED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "401":
          description: UNAUTHENTICATED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "403":
          description: ACCOUNT_NOT_VERIFIED, ACCOUNT_SUSPENDED, SCOPE_MISSING
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
        "429":
          description: RATE_LIMITED
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ErrorResponse"
components:
  securitySchemes:
    apiKey:
      type: http
      scheme: bearer
      description: "Authorization: Bearer tr_live_... or tr_test_..."
  schemas:
    ErrorResponse:
      type: object
      properties:
        success:
          type: boolean
          const: false
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - VALIDATION_FAILED
                - IDEMPOTENCY_KEY_REQUIRED
                - UNAUTHENTICATED
                - INSUFFICIENT_BALANCE
                - ACCOUNT_NOT_VERIFIED
                - ACCOUNT_SUSPENDED
                - SCOPE_MISSING
                - COUNTRY_NOT_ALLOWED
                - NOT_FOUND
                - IDEMPOTENCY_CONFLICT
                - MESSAGE_TOO_LONG
                - INVALID_PHONE_NUMBER
                - RATE_LIMITED
                - QUOTA_EXCEEDED
                - OTP_COOLDOWN
                - INTERNAL
                - SERVICE_PAUSED
            message:
              type: string
            fields:
              type: array
              items:
                type: object
                properties:
                  path:
                    type: string
                  message:
                    type: string
                required:
                  - path
                  - message
          required:
            - code
            - message
      required:
        - success
        - error
    OtpPurpose:
      type: string
      pattern: ^[a-z0-9_-]{1,32}$
    OtpSendRequest:
      type: object
      properties:
        phoneNumber:
          type: string
          pattern: ^\+[1-9]\d{7,14}$
        purpose:
          $ref: "#/components/schemas/OtpPurpose"
      required:
        - phoneNumber
        - purpose
      additionalProperties: false
    OtpSendResponse:
      type: object
      properties:
        success:
          type: boolean
          const: true
        requestId:
          type: string
          pattern: ^otp_[0-9A-HJKMNP-TV-Z]{26}$
        status:
          type: string
          const: queued
        expiresIn:
          type: integer
          exclusiveMinimum: 0
          maximum: 9007199254740991
        sendingAvailable:
          type: boolean
        notice:
          type:
            - string
            - "null"
      required:
        - success
        - requestId
        - status
        - expiresIn
        - sendingAvailable
        - notice
    OtpVerifyRequest:
      type: object
      properties:
        requestId:
          type: string
          pattern: ^otp_[0-9A-HJKMNP-TV-Z]{26}$
        code:
          type: string
          pattern: ^\d{6}$
      required:
        - requestId
        - code
      additionalProperties: false
    OtpVerifyResponse:
      type: object
      properties:
        success:
          type: boolean
          const: true
        verified:
          type: boolean
      required:
        - success
        - verified
    SendSmsRequest:
      type: object
      properties:
        phoneNumber:
          type: string
          pattern: ^\+[1-9]\d{7,14}$
        message:
          type: string
          minLength: 1
      required:
        - phoneNumber
        - message
      additionalProperties: false
    SendSmsResponse:
      type: object
      properties:
        success:
          type: boolean
          const: true
        id:
          type: string
          pattern: ^job_[0-9A-HJKMNP-TV-Z]{26}$
        status:
          type: string
          enum:
            - queued
            - sending
            - sent
            - delivered
            - failed
            - expired
            - unknown
        encoding:
          type: string
          enum:
            - GSM7
            - UCS2
        chars:
          type: integer
          minimum: -9007199254740991
          maximum: 9007199254740991
        priceCents:
          type: integer
          minimum: 0
          maximum: 9007199254740991
        expiresAt:
          type: string
          format: date-time
          pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$
        testMode:
          type: boolean
        sendingAvailable:
          type: boolean
        notice:
          type:
            - string
            - "null"
      required:
        - success
        - id
        - status
        - encoding
        - chars
        - priceCents
        - expiresAt
        - testMode
        - sendingAvailable
        - notice
    SmsStatusResponse:
      type: object
      properties:
        success:
          type: boolean
          const: true
        sms:
          $ref: "#/components/schemas/SmsView"
      required:
        - success
        - sms
    SmsView:
      type: object
      properties:
        id:
          type: string
          pattern: ^job_[0-9A-HJKMNP-TV-Z]{26}$
        status:
          type: string
          enum:
            - queued
            - sending
            - sent
            - delivered
            - failed
            - expired
            - unknown
        phoneNumber:
          type: string
        country:
          anyOf:
            - type: string
              pattern: ^[A-Z]{2}$
            - type: "null"
        priceCents:
          type: integer
          minimum: 0
          maximum: 9007199254740991
        charged:
          type: boolean
        lastError:
          anyOf:
            - type: string
              enum:
                - NUMBER_REJECTED
                - MESSAGE_TOO_LONG
                - CARRIER_REJECTED
                - SEND_FAILED
                - CANCELLED
                - OUTCOME_UNKNOWN
                - DELIVERY_FAILED
                - EXPIRED_UNSENT
            - type: "null"
        createdAt:
          type: string
          format: date-time
          pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$
        sentAt:
          anyOf:
            - type: string
              format: date-time
              pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$
            - type: "null"
        deliveredAt:
          anyOf:
            - type: string
              format: date-time
              pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$
            - type: "null"
        expiresAt:
          type: string
          format: date-time
          pattern: ^(?:(?:\d\d[2468][048]|\d\d[13579][26]|\d\d0[48]|[02468][048]00|[13579][26]00)-02-29|\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\d|30)|(?:02)-(?:0[1-9]|1\d|2[0-8])))T(?:(?:[01]\d|2[0-3]):[0-5]\d:[0-5]\d(?:\.\d+)?(?:Z))$
        testMode:
          type: boolean
        notice:
          type:
            - string
            - "null"
      required:
        - id
        - status
        - phoneNumber
        - country
        - priceCents
        - charged
        - lastError
        - createdAt
        - sentAt
        - deliveredAt
        - expiresAt
        - testMode
        - notice
