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

# Cancel an order that has not arrived, and get all of it back

> Ends an order that is still `awaiting_provisioning` and refunds the whole payment.

CANCEL OR RETURN, and the difference is whether anything was ever delivered. An order still awaiting provisioning is one where the shelf was empty when it settled: nothing arrived, so all of the money goes back and this is the route. An order that IS provisioned has been in your hands, and `POST /v1/fulfilments/{id}/return` is the route for that. Calling the wrong one answers 409 naming the state the order is actually in, rather than quietly doing the other thing.

PRESSING IT TWICE IS SAFE AND IS NOT AN ERROR, and you do not have to do anything to make it so: the key is derived from the order and the action, so every retry of one cancellation lands on the same one whether or not you send anything. Send no `Idempotency-Key` here; this route does not read one.

A REPEAT WITHIN 24 HOURS IS ANSWERED WITH THE FIRST ATTEMPT'S ANSWER, unchanged, and carries `Idempotent-Replayed: true`. After that the order is simply found already ended and answers 200 with `alreadyDone: true`. Either way the payment provider is asked for nothing, so a double-clicked button cannot produce a second refund. Two calls that arrive AT THE SAME TIME do not both proceed: one does the work and the other is told so with 409 `idempotency_conflict` and `retryable: true`.

ADMIN, AND A SIGNED-IN PRINCIPAL ONLY, the same rule the purchase has and for the reason turned around: if only an admin can commit the organisation to a subscription, only an admin can end one. An API key is refused, because undoing a purchase is not something a program should do while nobody is watching.

NOTHING IS RECORDED UNTIL THE PROVIDER HAS AGREED. If the refund cannot be arranged, the order is left exactly as it was and you are told to try again. An order marked cancelled with the money still taken would look like success from every screen, which is why the order of operations is this way round.

Hosted deployments only. A local checkout does not mount this route, so calling it there is a 404.



## OpenAPI

````yaml /api/openapi.json post /v1/fulfilments/{id}/cancel
openapi: 3.1.0
info:
  title: Phonebase HTTP API
  version: 0.1.0
  summary: >-
    Every HTTP operation the control plane mounts, generated from the app's own
    route table.
  description: >-
    The control plane exposes two HTTP surfaces, and reading the boundary
    between them matters more than reading any single endpoint.


    The CONTROL surface is what your backend and your operators call. It takes a
    bearer token in the `Authorization` header, and two credential channels
    resolve to the same authorization context: an OAuth 2.1 access token for
    interactive clients, or an org scoped API key for headless ones.


    The DEVICE surface, under `/vrx/{runToken}/v1`, is what a worker calls while
    executing one run. Its credential is the run token in the url path, and it
    reads no `Authorization` header at all.


    Device control itself is not REST. An agent taps, swipes and reads the
    screen over the Model Context Protocol at `POST /mcp`, one tool call at a
    time. There is no `POST /v1/devices/{id}/tap`, and if you are looking for
    one, start with the tool reference instead.


    Operations marked `"x-phonebase-modes": ["hosted"]` exist only on a hosted
    deployment; a local checkout does not mount them.
servers:
  - url: '{origin}'
    description: Your control plane origin.
    variables:
      origin:
        default: https://mcp.phonebase.co
        enum:
          - https://mcp.phonebase.co
          - http://127.0.0.1:8788
        description: >-
          The hosted deployment serves the public origin its metadata document
          advertises, and that is the default here. Select the loopback entry
          instead when you are running a local checkout, which serves the port
          `PORT` names.
security: []
tags:
  - name: Apps
    description: Save immutable application builds and manage explicit device operations.
  - name: Managed Proxy
    description: >-
      Organization-owned fixed egress resources, independent one-time orders and
      verified network relationships.
  - name: Health
    description: Liveness, unauthenticated.
  - name: Gateway enrollment
    description: >-
      How a new gateway computer trades a one-time enrollment code for its
      tunnel credential. Unauthenticated: the code is the credential.
  - name: Gateway releases
    description: >-
      What a gateway computer installs: the current edge release of a channel.
      Called with the gateway's own credential.
  - name: MCP
    description: The JSON-RPC endpoint an agent drives devices through.
  - name: Copilot
    description: >-
      Drive a device by chatting. A signed in person's own conversations,
      streamed. Hosted deployments only.
  - name: Discovery
    description: Where to authenticate. Served on hosted deployments only.
  - name: Runs
    description: Start, watch and steer a run.
  - name: API keys
    description: Mint and revoke headless credentials. Hosted deployments only.
  - name: Device surface
    description: What a worker calls with its run token to observe and act.
  - name: Usage
    description: Read your org's own receipts and consumption. Hosted deployments only.
  - name: Devices
    description: Read the fleet your credential can see. Hosted deployments only.
  - name: Notifications
    description: >-
      What a person needs to know when they are not watching, and which emails
      they receive. Hosted deployments only.
  - name: Webhooks
    description: >-
      Subscribe your own system to what happens here, and read what was
      delivered. Hosted deployments only.
  - name: Provisioning
    description: >-
      Whether an organisation is provisioned, and how it becomes so. Hosted
      deployments only, and both operations answer a declared 503 on a
      deployment that cannot provision yet.
paths:
  /v1/fulfilments/{id}/cancel:
    post:
      tags:
        - Provisioning
      summary: Cancel an order that has not arrived, and get all of it back
      description: >-
        Ends an order that is still `awaiting_provisioning` and refunds the
        whole payment.


        CANCEL OR RETURN, and the difference is whether anything was ever
        delivered. An order still awaiting provisioning is one where the shelf
        was empty when it settled: nothing arrived, so all of the money goes
        back and this is the route. An order that IS provisioned has been in
        your hands, and `POST /v1/fulfilments/{id}/return` is the route for
        that. Calling the wrong one answers 409 naming the state the order is
        actually in, rather than quietly doing the other thing.


        PRESSING IT TWICE IS SAFE AND IS NOT AN ERROR, and you do not have to do
        anything to make it so: the key is derived from the order and the
        action, so every retry of one cancellation lands on the same one whether
        or not you send anything. Send no `Idempotency-Key` here; this route
        does not read one.


        A REPEAT WITHIN 24 HOURS IS ANSWERED WITH THE FIRST ATTEMPT'S ANSWER,
        unchanged, and carries `Idempotent-Replayed: true`. After that the order
        is simply found already ended and answers 200 with `alreadyDone: true`.
        Either way the payment provider is asked for nothing, so a
        double-clicked button cannot produce a second refund. Two calls that
        arrive AT THE SAME TIME do not both proceed: one does the work and the
        other is told so with 409 `idempotency_conflict` and `retryable: true`.


        ADMIN, AND A SIGNED-IN PRINCIPAL ONLY, the same rule the purchase has
        and for the reason turned around: if only an admin can commit the
        organisation to a subscription, only an admin can end one. An API key is
        refused, because undoing a purchase is not something a program should do
        while nobody is watching.


        NOTHING IS RECORDED UNTIL THE PROVIDER HAS AGREED. If the refund cannot
        be arranged, the order is left exactly as it was and you are told to try
        again. An order marked cancelled with the money still taken would look
        like success from every screen, which is why the order of operations is
        this way round.


        Hosted deployments only. A local checkout does not mount this route, so
        calling it there is a 404.
      operationId: postV1FulfilmentsByIdCancel
      parameters:
        - name: id
          in: path
          required: true
          description: The order id, as `GET /v1/fulfilments` reports it.
          schema:
            type: string
      responses:
        '200':
          description: The order is ended.
          headers:
            Idempotent-Replayed:
              description: >-
                `true` when this answer was recorded by an earlier attempt and
                handed back unchanged rather than produced now. Absent
                otherwise.
              schema:
                type: string
                enum:
                  - 'true'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/OrderChanged'
        '400':
          description: The id in the path is not one this service could have stored.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '401':
          description: No credential, or one that was refused.
          headers:
            WWW-Authenticate:
              description: >-
                A bearer challenge. On a hosted deployment it names the metadata
                document to discover the authorization server from, and it adds
                `error="invalid_token"` when a credential was presented and
                rejected.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UnauthorizedError'
        '403':
          description: >-
            Only a signed-in org admin can change an order; an API key is
            refused by design.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '404':
          description: No such order of yours.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/NotFoundError'
        '409':
          description: >-
            `wrong_state`: this order is not awaiting provisioning. The body
            names the state it IS in, so a client can offer the right action
            instead. A provisioned order is returned, not cancelled.


            `idempotency_conflict` with `retryable: true`: another attempt at
            this same cancellation is running right now. Nothing is wrong; send
            the same request again in a moment.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
        '413':
          description: >-
            The request body exceeds the server's limit (1 MiB). Refused before
            the body is parsed, so nothing was partially applied. Retrying the
            same body will fail the same way: send less. Per-field limits are
            tighter than this and are documented on the fields themselves; this
            is the backstop underneath them. The shape is the same on ordinary
            JSON surfaces, /vrx included. The explicitly configured APK content
            stream documents its own byte ceiling.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PayloadTooLargeError'
        '429':
          description: >-
            Over the request budget for this caller. Hosted deployments only.
            Which budget applied depends on whether this request carried an
            `Authorization` header, not on which endpoint it was aimed at. Wait
            `Retry-After` seconds and retry the same request: the refusal
            happens before any work, so nothing was partially applied. See
            /api/rate-limits.
          headers:
            Retry-After:
              description: >-
                Whole seconds until one request is available again. Computed
                from the caller's own remaining allowance, not a fixed constant.
              schema:
                type: string
            RateLimit-Limit:
              description: >-
                The budget in effect for this caller class, in requests per
                minute.
              schema:
                type: string
            RateLimit-Remaining:
              description: >-
                Whole requests that were still available when this request was
                admitted. Zero on a 429.
              schema:
                type: string
            RateLimit-Reset:
              description: >-
                Seconds until the allowance is back to full. Longer than
                `Retry-After`, which is the wait for a single request.
              schema:
                type: string
            RateLimit-Policy:
              description: The budget and its window, as `<limit>;w=60`.
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/RateLimitedError'
        '503':
          description: >-
            The refund could not be arranged: no payment provider configured
            here, the provider did not answer, or this order has no settled
            payment behind it for a person to work from. Nothing was changed.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProvisioningUnavailableError'
      security:
        - bearerAuth: []
components:
  schemas:
    OrderChanged:
      type: object
      properties:
        id:
          type: string
          description: The order this answers about.
        state:
          type: string
          enum:
            - cancelled
          description: >-
            What the order is now. Always `cancelled`: both of these routes end
            an order.
        alreadyDone:
          type: boolean
          description: >-
            TRUE when the order had already been ended before this call, so
            nothing was asked of the payment provider and no second refund was
            issued. Both cases answer 200. Pressing the button twice is not an
            error and must not read like one, and this is how a client tells
            them apart.
        deviceId:
          type:
            - string
            - 'null'
          description: >-
            The device that went back, on a return. Null on a cancellation,
            where no device had been provided, and null on a repeated call.
        leaseCut:
          type: boolean
          description: >-
            Whether a session that was live at that moment was ended by this.
            False for the ordinary return of an idle device, and false on any
            deployment configured to let sessions expire on their own.
      required:
        - id
        - state
        - alreadyDone
      additionalProperties: false
      description: What ending an order did.
    Error:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - backend_resolution_failed
                - backend_not_implemented
                - device_unavailable
                - device_disconnected
                - lease_mismatch
                - lease_required
                - capability_unsupported
                - invalid_argument
                - backend_command_failed
                - idempotency_conflict
                - action_outcome_unknown
                - stale_frame
                - internal_error
                - unauthorized
                - permission_denied
                - run_not_found
                - frame_not_found
                - device_busy
                - admission_unavailable
                - checkout_unavailable
                - checkout_customer_balance
                - checkout_in_progress
                - quote_changed
                - payment_in_progress
                - out_of_stock
                - wrong_state
                - instance_not_served
                - reverification_required
                - not_found
                - receipt_not_found
                - rate_limited
                - payload_too_large
                - origin_not_allowed
                - api_key_from_browser_origin
                - key_predates_instance_attribution
                - org_not_served_by_instance
                - tenant_binding_unavailable
                - tenant_binding_unsettled
                - fleet_at_capacity
                - forbidden
                - service_unavailable
                - invalid_signature
                - invalid_event
                - not_settled
              description: >-
                The closed set of codes this service answers with on purpose, on
                MCP tools and REST alike (D152). What each one means and when
                you get it is on the errors page.
            message:
              type: string
              description: A human readable summary. Do not branch on it, branch on `code`.
            retryable:
              type: boolean
              description: >-
                Whether re-issuing the same call unchanged is meaningful. It is
                a property of the occurrence, not of the code, so read the field
                rather than inferring it.
          required:
            - code
            - message
          additionalProperties: true
          description: >-
            Specific errors add their own fields, such as `deviceId`, `reason`
            or `capability`. Read the ones you branch on and ignore the rest.
      required:
        - error
      additionalProperties: false
      description: >-
        The control surface error envelope. Every non 2xx JSON body on this
        surface has this shape.
    UnauthorizedError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              const: unauthorized
              description: Always `unauthorized` on this response.
            reason:
              type: string
              enum:
                - missing_credentials
                - malformed_credentials
                - invalid_token
                - token_expired
                - wrong_audience
                - missing_org
                - missing_org_role
                - unknown_key
                - revoked_key
                - expired_key
              description: The closed set of ways authentication can fail.
            hint:
              type: string
              description: >-
                A next step in prose, on the reasons where there is one to give:
                `revoked_key`, `expired_key`, `missing_org_role`, and
                `invalid_token` when the account is not a member of the named
                organization. Absent otherwise, so branch on `reason` and show
                this if it is there.
          required:
            - code
            - reason
          additionalProperties: false
      required:
        - error
      additionalProperties: false
      description: >-
        A refused credential. `unknown_key` covers both a keyId nobody has and a
        secret that does not match, because telling those apart would enumerate
        keys for you. Revocation and expiry are distinguishable, since you owned
        that key.
    NotFoundError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              const: not_found
              description: Always `not_found` on this response.
            message:
              type: string
              description: >-
                A human readable summary naming the kind of thing that was not
                found.
          required:
            - code
            - message
          additionalProperties: false
      required:
        - error
      additionalProperties: false
      description: >-
        No such thing that you may act on. Not an existence oracle over other
        organisations' ids.
    PayloadTooLargeError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              const: payload_too_large
              description: Always `payload_too_large` on this response.
            message:
              type: string
              description: A human readable summary naming the limit in bytes.
          required:
            - code
            - message
          additionalProperties: false
      required:
        - error
      additionalProperties: false
      description: >-
        Returned when the request body exceeds the server limit. Identical on
        every surface, including /vrx.
    RateLimitedError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              const: rate_limited
              description: Always `rate_limited` on this response.
            message:
              type: string
              description: >-
                A human readable summary. The numbers are in the headers, not in
                here.
          required:
            - code
            - message
          additionalProperties: false
      required:
        - error
      additionalProperties: false
      description: >-
        Over the request budget. Raised by the rate limiter before any handler
        runs, which is why its code is not one of the action codes: nothing was
        attempted, so no action failed.
    ProvisioningUnavailableError:
      type: object
      properties:
        error:
          type: object
          properties:
            code:
              type: string
              enum:
                - admission_unavailable
                - checkout_unavailable
              description: >-
                `admission_unavailable`: the provisioning store cannot be read
                right now; retry after `Retry-After`. `checkout_unavailable`:
                this deployment is not configured for what was asked.
            message:
              type: string
              description: A human readable summary. Do not branch on it, branch on `code`.
          required:
            - code
            - message
          additionalProperties: false
      required:
        - error
      additionalProperties: false
      description: Provisioning cannot answer on this deployment right now.
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        The control surface credential. Send `Authorization: Bearer <token>`.


        Two kinds of token are accepted and they are told apart by shape, not by
        a separate header. A token beginning `pbk_` is an org scoped API key,
        whose public half and secret half are generated together and of which
        only a hash of the secret is ever stored; anything else is treated as an
        OAuth 2.1 access token and verified against the authorization server's
        keys.


        Both resolve to the same context: an org, a principal and a set of
        scopes. Nothing downstream branches on which channel you used, with one
        deliberate exception, key management, which requires a signed-in person
        so that a key can never mint another key.


        Scopes are enforced when MCP tools are REGISTERED rather than when they
        are called, so a tool your credential cannot use is absent from
        `tools/list` rather than refused mid gesture.

````

This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.