{
  "openapi": "3.0.3",
  "info": {
    "title": "RouteCloud Payment Gateway API",
    "version": "1.1.0",
    "description": "Ask for a payment, hand the customer a short code, and get told when it is paid - whether they paid at the paybill offline or through an STK prompt. Both settle the same request. Full worked examples at /integrate.\n\nSingle-paybill payment routing over the Safaricom Daraja APIs.\n\nCustomers pay one M-PESA paybill. The gateway reads the account reference, decides which downstream platform the money belongs to, posts it to a double-entry ledger and notifies that platform over a signed webhook.\n\n**Authentication** \u2014 OAuth2 client credentials. Exchange your `client_id` / `client_secret` at `/oauth/token` for a bearer token valid for one hour.\n\n**Idempotency** \u2014 send an `Idempotency-Key` header on every write. Replaying the same key with the same body returns the original response. Reusing a key with a different body is a 422.\n\n**Correlation** \u2014 send `X-Correlation-Id` and it is threaded through every log line, M-PESA call and webhook for that payment.\n\n**Errors** \u2014 every error is an RFC 7807 problem document.",
    "contact": {
      "name": "RouteCloud support",
      "email": "support@route.cloud.or.ke"
    }
  },
  "servers": [
    {
      "url": "https://route.cloud.or.ke/api/v1",
      "description": "Production"
    }
  ],
  "tags": [
    {
      "name": "Authentication",
      "description": "Token issuance and introspection"
    },
    {
      "name": "Payment requests",
      "description": "Invoices: ask for money, get a code, be told when it is settled"
    },
    {
      "name": "Collections",
      "description": "Taking money in"
    },
    {
      "name": "Disbursements",
      "description": "Sending money out"
    },
    {
      "name": "Transactions",
      "description": "The payment register"
    },
    {
      "name": "Routing",
      "description": "Destinations and rule simulation"
    },
    {
      "name": "Webhooks",
      "description": "Delivery status and replay"
    },
    {
      "name": "Reports",
      "description": "Ledger and volume reporting"
    },
    {
      "name": "System",
      "description": "Health"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "paths": {
    "/oauth/token": {
      "post": {
        "tags": [
          "Authentication"
        ],
        "summary": "Exchange client credentials for a bearer token",
        "description": "Credentials may be sent as HTTP Basic (preferred) or in the form body. A client may request a subset of its granted scopes, never a superset.",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "required": [
                  "grant_type"
                ],
                "properties": {
                  "grant_type": {
                    "type": "string",
                    "enum": [
                      "client_credentials"
                    ]
                  },
                  "client_id": {
                    "type": "string"
                  },
                  "client_secret": {
                    "type": "string"
                  },
                  "scope": {
                    "type": "string",
                    "example": "payments:write payments:read"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Token issued",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "access_token": {
                      "type": "string"
                    },
                    "token_type": {
                      "type": "string",
                      "example": "Bearer"
                    },
                    "expires_in": {
                      "type": "integer",
                      "example": 3600
                    },
                    "scope": {
                      "type": "string"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Unsupported grant type or invalid scope"
          },
          "401": {
            "description": "Client authentication failed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/oauth/revoke": {
      "post": {
        "tags": [
          "Authentication"
        ],
        "summary": "Revoke a token immediately",
        "description": "Always answers 200 so revocation cannot be used to probe which tokens exist (RFC 7009).",
        "security": [],
        "requestBody": {
          "required": true,
          "content": {
            "application/x-www-form-urlencoded": {
              "schema": {
                "type": "object",
                "properties": {
                  "token": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Acknowledged"
          }
        }
      }
    },
    "/oauth/introspect": {
      "get": {
        "tags": [
          "Authentication"
        ],
        "summary": "What the current token can do",
        "responses": {
          "200": {
            "description": "Token metadata",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "active": {
                      "type": "boolean"
                    },
                    "client_id": {
                      "type": "string"
                    },
                    "client_name": {
                      "type": "string"
                    },
                    "scope": {
                      "type": "string"
                    },
                    "destination_scoped": {
                      "type": "boolean"
                    },
                    "rate_limit_per_min": {
                      "type": "integer"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthenticated"
          }
        }
      }
    },
    "/payments/stk-push": {
      "post": {
        "tags": [
          "Collections"
        ],
        "summary": "Send a Lipa na M-PESA prompt to a customer handset",
        "description": "Asynchronous. A 202 means M-PESA accepted the request, not that the customer paid \u2014 the outcome arrives on your webhook and on `GET /transactions/{id}`.\n\nThe account reference decides which destination receives the money. M-PESA truncates `account_ref` to 12 characters and `description` to 13 on the handset.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/CorrelationId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "msisdn",
                  "amount",
                  "account_ref"
                ],
                "properties": {
                  "msisdn": {
                    "type": "string",
                    "example": "254712345678",
                    "description": "Any Kenyan format: 07\u2026, 2547\u2026, +2547\u2026"
                  },
                  "amount": {
                    "type": "number",
                    "example": 1500,
                    "description": "Whole shillings, minimum 1"
                  },
                  "account_ref": {
                    "type": "string",
                    "maxLength": 20,
                    "example": "SCH-4821"
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 60,
                    "example": "Term 2 fees"
                  },
                  "paybill": {
                    "type": "string",
                    "example": "4320161",
                    "description": "Defaults to the configured collection paybill"
                  },
                  "callback_url": {
                    "type": "string",
                    "format": "uri",
                    "description": "Override the gateway callback. HTTPS only."
                  }
                }
              },
              "example": {
                "msisdn": "254712345678",
                "amount": 1500,
                "account_ref": "SCH-4821",
                "description": "Term 2 fees"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted by M-PESA; awaiting the customer",
            "content": {
              "application/json": {
                "example": {
                  "transaction_id": "9f2c1e40-5b7a-4d1e-9a33-0c7f9b21d5aa",
                  "reference": "RC-8FK2QD9L",
                  "status": "awaiting_callback",
                  "amount": 1500,
                  "account_reference": "4821",
                  "destination": "SCHOOLPAY",
                  "customer_message": "Success. Request accepted for processing",
                  "checkout_request_id": "ws_CO_14082026150021123456",
                  "expires_at": "2026-08-14T15:02:21+03:00"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "409": {
            "$ref": "#/components/responses/IdempotencyInFlight"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          },
          "502": {
            "$ref": "#/components/responses/MpesaError"
          }
        }
      }
    },
    "/payments/simulate": {
      "post": {
        "tags": [
          "Collections"
        ],
        "summary": "Simulate a paybill payment (sandbox only)",
        "description": "Production C2B must come from a real handset via the M-PESA menu, USSD or app.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "msisdn",
                  "amount",
                  "account_ref"
                ],
                "properties": {
                  "msisdn": {
                    "type": "string"
                  },
                  "amount": {
                    "type": "number"
                  },
                  "account_ref": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Simulation submitted"
          },
          "403": {
            "description": "Not available outside the sandbox"
          }
        }
      }
    },
    "/payments/disburse": {
      "post": {
        "tags": [
          "Disbursements"
        ],
        "summary": "Send money to a customer (B2C)",
        "description": "Checked against the client's remaining daily cap and the last known float before anything is sent. Every request carries a unique OriginatorConversationID, so a retried timeout cannot double-pay.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/CorrelationId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "msisdn",
                  "amount",
                  "remarks"
                ],
                "properties": {
                  "msisdn": {
                    "type": "string",
                    "example": "254712345678"
                  },
                  "amount": {
                    "type": "number",
                    "example": 2500
                  },
                  "remarks": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 100,
                    "example": "Refund for order 8821"
                  },
                  "command_id": {
                    "type": "string",
                    "enum": [
                      "BusinessPayment",
                      "SalaryPayment",
                      "PromotionPayment"
                    ],
                    "default": "BusinessPayment"
                  },
                  "occasion": {
                    "type": "string",
                    "maxLength": 100
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Accepted by M-PESA; result arrives on the callback"
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "422": {
            "description": "Blocked by a cap, insufficient float, or validation"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/payments/{id}/reverse": {
      "post": {
        "tags": [
          "Disbursements"
        ],
        "summary": "Reverse a collection",
        "description": "Returns money to the customer and debits the destination balance once M-PESA confirms.",
        "parameters": [
          {
            "$ref": "#/components/parameters/TransactionId"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "reason"
                ],
                "properties": {
                  "reason": {
                    "type": "string",
                    "minLength": 2,
                    "maxLength": 100
                  },
                  "amount": {
                    "type": "number",
                    "description": "Defaults to the full original amount"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Reversal submitted"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Already reversed"
          },
          "422": {
            "description": "Not reversible in its current state"
          }
        }
      }
    },
    "/transactions": {
      "get": {
        "tags": [
          "Transactions"
        ],
        "summary": "Search the transaction register",
        "description": "A client scoped to one destination only ever sees that destination's transactions. Searching by `msisdn` goes through a blind index \u2014 the plaintext number never appears in a query.",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma separated: success, pending, awaiting_callback, failed, cancelled, timeout, suspense, reversed"
          },
          {
            "name": "channel",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "c2b, stk_push, b2c, b2b, reversal"
          },
          {
            "name": "direction",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "collection",
                "disbursement"
              ]
            }
          },
          {
            "name": "destination",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Destination code"
          },
          {
            "name": "account_ref",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "msisdn",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "receipt",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "M-PESA receipt number"
          },
          {
            "name": "reference",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "reconciliation_status",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "min_amount",
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "max_amount",
            "in": "query",
            "schema": {
              "type": "number"
            }
          },
          {
            "name": "sort",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "created_at",
                "amount_cents",
                "completed_at",
                "status"
              ]
            }
          },
          {
            "name": "order",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "asc",
                "desc"
              ]
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer",
              "minimum": 1
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 200,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Matching transactions",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Transaction"
                      }
                    },
                    "meta": {
                      "$ref": "#/components/schemas/Pagination"
                    },
                    "summary": {
                      "type": "object",
                      "properties": {
                        "successful_gross": {
                          "type": "number"
                        },
                        "successful_fees": {
                          "type": "number"
                        },
                        "successful_net": {
                          "type": "number"
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          }
        }
      }
    },
    "/transactions/{id}": {
      "get": {
        "tags": [
          "Transactions"
        ],
        "summary": "One transaction",
        "description": "Accepts the public id, the human reference (RC-\u2026) or the M-PESA receipt.",
        "parameters": [
          {
            "$ref": "#/components/parameters/TransactionId"
          },
          {
            "name": "include_raw",
            "in": "query",
            "schema": {
              "type": "boolean"
            },
            "description": "Include the verbatim Daraja request, response and callback bodies"
          }
        ],
        "responses": {
          "200": {
            "description": "The transaction, its split legs and its webhook delivery status",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Transaction"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/transactions/{id}/ledger": {
      "get": {
        "tags": [
          "Reports"
        ],
        "summary": "Double-entry postings for a transaction",
        "parameters": [
          {
            "$ref": "#/components/parameters/TransactionId"
          }
        ],
        "responses": {
          "200": {
            "description": "Ledger entries"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/transactions/{id}/status-query": {
      "post": {
        "tags": [
          "Transactions"
        ],
        "summary": "Ask M-PESA what actually happened",
        "description": "The safety net when no callback arrives. For STK the answer is synchronous; for everything else it arrives asynchronously and updates the transaction. Capped per transaction.",
        "parameters": [
          {
            "$ref": "#/components/parameters/TransactionId"
          }
        ],
        "responses": {
          "200": {
            "description": "Synchronous answer (STK)"
          },
          "202": {
            "description": "Query submitted; the transaction updates shortly"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "description": "Query limit for this transaction reached"
          }
        }
      }
    },
    "/destinations": {
      "get": {
        "tags": [
          "Routing"
        ],
        "summary": "List destination platforms",
        "responses": {
          "200": {
            "description": "Destinations",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Destination"
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/destinations/{code}": {
      "get": {
        "tags": [
          "Routing"
        ],
        "summary": "One destination with its routing rules and today's volume",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Destination detail"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/destinations/{code}/balance": {
      "get": {
        "tags": [
          "Reports"
        ],
        "summary": "Current balance owed to a platform",
        "description": "`ledger_consistent` is false if the cached balance disagrees with the derived ledger balance \u2014 treat that as a defect, not a rounding artefact.",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Balance",
            "content": {
              "application/json": {
                "example": {
                  "destination": "SCHOOLPAY",
                  "balance": 148920.5,
                  "settled_to_date": 2310400,
                  "currency": "KES",
                  "ledger_consistent": true,
                  "as_of": "2026-08-14T15:00:00+03:00"
                }
              }
            }
          }
        }
      }
    },
    "/routing/simulate": {
      "post": {
        "tags": [
          "Routing"
        ],
        "summary": "Dry-run an account reference against the live rules",
        "description": "No money moves. Returns the winning rule, the resulting split legs and the full evaluation trace \u2014 this is how you debug why a payment went where it did.",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "account_ref",
                  "amount"
                ],
                "properties": {
                  "account_ref": {
                    "type": "string",
                    "example": "sch 4821"
                  },
                  "amount": {
                    "type": "number",
                    "example": 1500
                  },
                  "msisdn": {
                    "type": "string"
                  },
                  "paybill": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Routing decision and trace",
            "content": {
              "application/json": {
                "example": {
                  "input": {
                    "account_reference": "sch 4821",
                    "normalised": "SCH4821",
                    "amount": 1500
                  },
                  "decision": {
                    "action": "route",
                    "rule_id": 1,
                    "rule_name": "School fees \u2192 SchoolPay",
                    "destination_code": "SCHOOLPAY",
                    "reason": "Matched rule 'School fees \u2192 SchoolPay'",
                    "via_confusable": false
                  },
                  "legs": [
                    {
                      "destination": "SCHOOLPAY",
                      "amount": 1500,
                      "fee": 22.5,
                      "net": 1477.5,
                      "account_reference_out": "4821"
                    }
                  ]
                }
              }
            }
          }
        }
      }
    },
    "/webhooks/events": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Delivery queue and status",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string",
              "enum": [
                "queued",
                "delivering",
                "delivered",
                "failed",
                "dead_letter",
                "cancelled"
              ]
            }
          },
          {
            "name": "event_type",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Events"
          }
        }
      }
    },
    "/webhooks/events/{uuid}/deliveries": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Every delivery attempt for an event",
        "description": "Includes the response body and latency of each attempt, so you can see exactly what your endpoint returned.",
        "parameters": [
          {
            "name": "uuid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Attempts"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/webhooks/events/{uuid}/replay": {
      "post": {
        "tags": [
          "Webhooks"
        ],
        "summary": "Re-queue a failed or dead-lettered event",
        "parameters": [
          {
            "name": "uuid",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "202": {
            "description": "Re-queued"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Already delivered successfully"
          }
        }
      }
    },
    "/reports/summary": {
      "get": {
        "tags": [
          "Reports"
        ],
        "summary": "Daily volume and value series",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          },
          {
            "name": "to",
            "in": "query",
            "schema": {
              "type": "string",
              "format": "date"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Totals and a per-day series"
          }
        }
      }
    },
    "/status": {
      "get": {
        "tags": [
          "System"
        ],
        "summary": "Health check",
        "description": "Unauthenticated and deliberately terse \u2014 safe to expose to an uptime monitor.",
        "security": [],
        "responses": {
          "200": {
            "description": "Healthy"
          },
          "503": {
            "description": "Degraded"
          }
        }
      }
    },
    "/payment-requests": {
      "post": {
        "tags": [
          "Payment requests"
        ],
        "summary": "Ask for a payment and mint a code",
        "description": "Returns a short code the customer can pay at the paybill, or that you can push as an STK prompt. Send your own id as external_reference - it comes back on every webhook, so no lookup table is needed.\n\nAmounts must be whole shillings: M-PESA cannot settle cents, and a code that can never be paid exactly is worse than a rejection.",
        "parameters": [
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          },
          {
            "$ref": "#/components/parameters/CorrelationId"
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "amount"
                ],
                "properties": {
                  "amount": {
                    "type": "number",
                    "example": 1500,
                    "description": "Whole shillings"
                  },
                  "destination": {
                    "type": "string",
                    "example": "SCHOOLPAY",
                    "description": "Where the money routes once paid"
                  },
                  "description": {
                    "type": "string",
                    "maxLength": 255,
                    "example": "Term 2 fees"
                  },
                  "external_reference": {
                    "type": "string",
                    "maxLength": 120,
                    "example": "ORDER-8821"
                  },
                  "msisdn": {
                    "type": "string",
                    "example": "0712345678",
                    "description": "Needed only to push a prompt, or to lock the code to one payer"
                  },
                  "customer_name": {
                    "type": "string",
                    "maxLength": 160
                  },
                  "expires_in_minutes": {
                    "type": "integer",
                    "minimum": 5,
                    "maximum": 129600,
                    "default": 1440
                  },
                  "allow_partial": {
                    "type": "boolean",
                    "default": false,
                    "description": "Code stays open until fully settled"
                  },
                  "allow_overpayment": {
                    "type": "boolean",
                    "default": true,
                    "description": "Refusing money at the till is usually worse than sorting it out after"
                  },
                  "require_exact": {
                    "type": "boolean",
                    "default": false,
                    "description": "Anything but the exact amount is refused before money moves"
                  },
                  "lock_to_msisdn": {
                    "type": "boolean",
                    "default": false,
                    "description": "Only the named payer may settle it"
                  },
                  "metadata": {
                    "type": "object",
                    "additionalProperties": true
                  }
                }
              },
              "example": {
                "amount": 1500,
                "destination": "SCHOOLPAY",
                "description": "Term 2 fees",
                "external_reference": "ORDER-8821",
                "msisdn": "0712345678"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaymentRequest"
                }
              }
            }
          },
          "403": {
            "$ref": "#/components/responses/InsufficientScope"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "get": {
        "tags": [
          "Payment requests"
        ],
        "summary": "List payment requests",
        "parameters": [
          {
            "name": "status",
            "in": "query",
            "schema": {
              "type": "string"
            },
            "description": "Comma separated"
          },
          {
            "name": "external_reference",
            "in": "query",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "page",
            "in": "query",
            "schema": {
              "type": "integer"
            }
          },
          {
            "name": "per_page",
            "in": "query",
            "schema": {
              "type": "integer",
              "maximum": 100
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Requests"
          }
        }
      }
    },
    "/payment-requests/{code}": {
      "get": {
        "tags": [
          "Payment requests"
        ],
        "summary": "One request, with every payment against it",
        "description": "The polling fallback if your endpoint was unreachable. Use the webhook as the primary path.",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The short code (e.g. YTA4XP) or the request uuid"
          }
        ],
        "responses": {
          "200": {
            "description": "The request",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PaymentRequest"
                }
              }
            }
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/payment-requests/{code}/stk-push": {
      "post": {
        "tags": [
          "Payment requests"
        ],
        "summary": "Push a prompt for an existing request",
        "description": "The prompt carries the same code as its account reference, so if the customer dismisses it and pays the paybill by hand, it still settles this one request.\n\n202 means M-PESA accepted the prompt, NOT that it was paid. Wait for the webhook.",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The short code (e.g. YTA4XP) or the request uuid"
          },
          {
            "$ref": "#/components/parameters/IdempotencyKey"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "msisdn": {
                    "type": "string",
                    "description": "Falls back to the number stored on the request"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "Prompt sent"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "Not open, expired, or nothing outstanding"
          }
        }
      }
    },
    "/payment-requests/{code}/cancel": {
      "post": {
        "tags": [
          "Payment requests"
        ],
        "summary": "Void an unsettled request",
        "parameters": [
          {
            "name": "code",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            },
            "description": "The short code (e.g. YTA4XP) or the request uuid"
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "reason": {
                    "type": "string"
                  }
                }
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Cancelled"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "description": "Already settled"
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "bearerFormat": "opaque"
      }
    },
    "parameters": {
      "TransactionId": {
        "name": "id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string"
        },
        "description": "Public id, RC- reference, or M-PESA receipt"
      },
      "IdempotencyKey": {
        "name": "Idempotency-Key",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string",
          "maxLength": 80
        },
        "description": "Replaying the same key with the same body returns the original response instead of moving money twice."
      },
      "CorrelationId": {
        "name": "X-Correlation-Id",
        "in": "header",
        "required": false,
        "schema": {
          "type": "string"
        },
        "description": "Threaded through every gateway log line, M-PESA call and webhook for this payment."
      }
    },
    "responses": {
      "Unauthenticated": {
        "description": "Missing, expired or revoked token",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "InsufficientScope": {
        "description": "Valid token, but the required scope was not granted",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "NotFound": {
        "description": "Not found, or outside this client's destination scope",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "ValidationFailed": {
        "description": "Field-level problems, listed under `errors`",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "IdempotencyInFlight": {
        "description": "A request with this key is still being processed",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Back off for Retry-After seconds",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Limit": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Remaining": {
            "schema": {
              "type": "integer"
            }
          },
          "X-RateLimit-Reset": {
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      },
      "MpesaError": {
        "description": "Safaricom rejected the request; `hint` says what to do about it",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Problem"
            }
          }
        }
      }
    },
    "schemas": {
      "Problem": {
        "type": "object",
        "description": "RFC 7807 problem detail",
        "properties": {
          "type": {
            "type": "string",
            "format": "uri"
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "integer"
          },
          "code": {
            "type": "string"
          },
          "errors": {
            "type": "object",
            "additionalProperties": {
              "type": "string"
            }
          },
          "hint": {
            "type": "string"
          }
        },
        "example": {
          "type": "https://route.cloud.or.ke/errors/validation_failed",
          "title": "The request could not be processed. See `errors` for details.",
          "status": 422,
          "code": "validation_failed",
          "errors": {
            "msisdn": "Customer mobile number must be a valid Kenyan mobile number."
          }
        }
      },
      "Pagination": {
        "type": "object",
        "properties": {
          "page": {
            "type": "integer"
          },
          "per_page": {
            "type": "integer"
          },
          "total": {
            "type": "integer"
          },
          "total_pages": {
            "type": "integer"
          }
        }
      },
      "Transaction": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "reference": {
            "type": "string",
            "example": "RC-8FK2QD9L"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "processing",
              "awaiting_callback",
              "success",
              "failed",
              "cancelled",
              "timeout",
              "reversed",
              "partially_reversed",
              "suspense"
            ]
          },
          "direction": {
            "type": "string",
            "enum": [
              "collection",
              "disbursement"
            ]
          },
          "channel": {
            "type": "string",
            "enum": [
              "c2b",
              "stk_push",
              "b2c",
              "b2b",
              "reversal",
              "qr",
              "ratiba",
              "manual"
            ]
          },
          "amount": {
            "type": "number"
          },
          "fee": {
            "type": "number"
          },
          "net_amount": {
            "type": "number"
          },
          "currency": {
            "type": "string",
            "example": "KES"
          },
          "customer": {
            "type": "object",
            "properties": {
              "msisdn": {
                "type": "string",
                "example": "254712***678",
                "description": "Masked unless the destination is opted in to full numbers"
              }
            }
          },
          "account_reference": {
            "type": "string",
            "description": "As delivered to the destination, after any transform"
          },
          "account_reference_as_entered": {
            "type": "string",
            "description": "Exactly what the customer typed"
          },
          "destination": {
            "type": "string",
            "nullable": true,
            "description": "null means the payment is in suspense"
          },
          "mpesa": {
            "type": "object",
            "properties": {
              "receipt": {
                "type": "string",
                "nullable": true
              },
              "shortcode": {
                "type": "string"
              },
              "checkout_request_id": {
                "type": "string",
                "nullable": true
              },
              "conversation_id": {
                "type": "string",
                "nullable": true
              },
              "result_code": {
                "type": "string",
                "nullable": true
              },
              "result_desc": {
                "type": "string",
                "nullable": true
              },
              "transacted_at": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "routing": {
            "type": "object",
            "properties": {
              "rule_id": {
                "type": "integer",
                "nullable": true
              },
              "is_split": {
                "type": "boolean"
              },
              "note": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "reconciliation": {
            "type": "object",
            "properties": {
              "id": {
                "type": "string",
                "nullable": true
              },
              "status": {
                "type": "string",
                "enum": [
                  "unreconciled",
                  "matched",
                  "disputed",
                  "written_off",
                  "manual"
                ]
              }
            }
          },
          "failure_reason": {
            "type": "string",
            "nullable": true
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "completed_at": {
            "type": "string",
            "format": "date-time",
            "nullable": true
          }
        }
      },
      "Destination": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "example": "SCHOOLPAY"
          },
          "name": {
            "type": "string"
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused",
              "suspended",
              "archived"
            ]
          },
          "balance": {
            "type": "number"
          },
          "settled_to_date": {
            "type": "number"
          },
          "currency": {
            "type": "string"
          },
          "fees": {
            "type": "object",
            "properties": {
              "percent": {
                "type": "number"
              },
              "flat": {
                "type": "number"
              },
              "cap": {
                "type": "number",
                "nullable": true
              }
            }
          },
          "webhook": {
            "type": "object",
            "properties": {
              "configured": {
                "type": "boolean"
              },
              "active": {
                "type": "boolean"
              },
              "health": {
                "type": "string",
                "enum": [
                  "healthy",
                  "degraded",
                  "failing",
                  "unknown"
                ]
              },
              "events": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "consecutive_failures": {
                "type": "integer"
              }
            }
          }
        }
      },
      "WebhookEvent": {
        "type": "object",
        "description": "The envelope POSTed to your endpoint. Signed with X-RouteCloud-Signature: t=<unix>,v1=<hmac-sha256 of \"t.rawBody\">. Deduplicate on `id`.",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "type": {
            "type": "string",
            "enum": [
              "payment_request.paid",
              "payment_request.partially_paid",
              "payment_request.overpaid",
              "payment_request.cancelled",
              "payment_request.expired",
              "payment.received",
              "payment.failed",
              "payment.reversed",
              "payment.held_in_suspense",
              "disbursement.completed",
              "disbursement.failed",
              "settlement.paid",
              "webhook.test"
            ]
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          },
          "destination": {
            "type": "string"
          },
          "livemode": {
            "type": "boolean"
          },
          "data": {
            "$ref": "#/components/schemas/Transaction"
          }
        }
      },
      "PaymentRequest": {
        "type": "object",
        "properties": {
          "code": {
            "type": "string",
            "example": "YTA4XP",
            "description": "What the customer keys in as the paybill account number"
          },
          "request_id": {
            "type": "string",
            "format": "uuid"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "partially_paid",
              "paid",
              "overpaid",
              "expired",
              "cancelled"
            ]
          },
          "external_reference": {
            "type": "string",
            "nullable": true,
            "description": "Your own id, echoed on every webhook"
          },
          "amount_expected": {
            "type": "number"
          },
          "amount_paid": {
            "type": "number"
          },
          "amount_outstanding": {
            "type": "number"
          },
          "currency": {
            "type": "string",
            "example": "KES"
          },
          "destination": {
            "type": "string",
            "nullable": true
          },
          "description": {
            "type": "string",
            "nullable": true
          },
          "customer": {
            "type": "object",
            "properties": {
              "msisdn": {
                "type": "string",
                "nullable": true,
                "example": "254712***678"
              },
              "name": {
                "type": "string",
                "nullable": true
              }
            }
          },
          "policy": {
            "type": "object",
            "properties": {
              "allow_partial": {
                "type": "boolean"
              },
              "allow_overpayment": {
                "type": "boolean"
              },
              "require_exact": {
                "type": "boolean"
              },
              "locked_to_payer": {
                "type": "boolean"
              }
            }
          },
          "pay": {
            "type": "object",
            "description": "Give these to the customer verbatim",
            "properties": {
              "paybill": {
                "type": "string",
                "example": "4320161"
              },
              "account_number": {
                "type": "string",
                "example": "YTA4XP"
              },
              "amount": {
                "type": "number"
              },
              "sms_text": {
                "type": "string"
              },
              "ussd": {
                "type": "string",
                "example": "*334#"
              }
            }
          },
          "payments": {
            "type": "array",
            "description": "Present on the detail endpoint",
            "items": {
              "type": "object",
              "properties": {
                "transaction_id": {
                  "type": "string",
                  "format": "uuid"
                },
                "reference": {
                  "type": "string"
                },
                "receipt": {
                  "type": "string",
                  "nullable": true
                },
                "channel": {
                  "type": "string",
                  "enum": [
                    "c2b",
                    "stk_push"
                  ],
                  "description": "c2b means they paid the paybill offline"
                },
                "amount": {
                  "type": "number"
                },
                "paid_at": {
                  "type": "string"
                }
              }
            }
          },
          "created_at": {
            "type": "string"
          },
          "expires_at": {
            "type": "string",
            "nullable": true
          },
          "settled_at": {
            "type": "string",
            "nullable": true
          }
        }
      }
    }
  }
}