{
  "openapi": "3.0.1",
  "info": {
    "title": "Ayrexa Public API",
    "description": "A read-only REST API for Enterprise organisations to pull their own invoices and customers into other systems, and signed webhooks that tell you the moment something happens.\n\nAuthentication: send your API key in the X-Api-Key header on every request. Create keys under Settings > API Keys (organisation administrators, Enterprise plan); the key is shown once, when it is created.\n\nPagination: list endpoints take page (from 1) and pageSize (1 to 200, default 20) and answer { total, page, pageSize, items }.\n\nRate limit: 600 requests per minute per client IP address across the whole API. Over the limit you get 429 with a Retry-After header.\n\nErrors: 401, 403 and 429 carry no body. 400 and 5xx carry RFC 7807 problem details with an error message and a traceId to quote to support.\n\nVersioning: the version is in the path (/api/public/v1). Within v1 changes are additive only; clients must ignore fields they do not know.",
    "contact": {
      "name": "Ayrexa support",
      "url": "https://ayrexa.com/contact"
    },
    "version": "1.0.0"
  },
  "servers": [
    {
      "url": "https://api.ayrexa.com",
      "description": "Ayrexa API"
    }
  ],
  "paths": {
    "/api/public/v1/invoices": {
      "get": {
        "tags": [
          "Invoices"
        ],
        "summary": "List invoices",
        "description": "Your organisation's invoices and quotations, whatever their status (drafts and cancelled documents included), newest invoice date first.",
        "operationId": "listInvoices",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "description": "The page to return, from 1. A smaller value is read as 1.",
            "schema": {
              "minimum": 1,
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "description": "How many items per page, from 1 to 200. A value outside the range is held to it.",
            "schema": {
              "maximum": 200,
              "minimum": 1,
              "type": "integer",
              "format": "int32",
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of results.",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/PublicInvoicePage"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicInvoicePage"
                },
                "example": {
                  "total": 57,
                  "page": 1,
                  "pageSize": 20,
                  "items": [
                    {
                      "invoiceNumber": "INV-0021",
                      "customerName": "BCX",
                      "invoiceDate": "2026-09-01T00:00:00Z",
                      "totalAmount": 11500,
                      "currencyCode": "ZAR",
                      "status": "Posted"
                    },
                    {
                      "invoiceNumber": "INV-0020",
                      "customerName": "Karoo Fresh Produce",
                      "invoiceDate": "2026-08-28T00:00:00Z",
                      "totalAmount": 2875.5,
                      "currencyCode": "ZAR",
                      "status": "Paid"
                    }
                  ]
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicInvoicePage"
                }
              }
            }
          },
          "400": {
            "description": "A query parameter could not be read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, unknown or revoked, or the organisation is suspended. No body."
          },
          "403": {
            "description": "The organisation is not on the Enterprise plan. No body."
          },
          "429": {
            "description": "More than 600 requests in a minute from this IP address. No body.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before trying again.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong on our side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    },
    "/api/public/v1/customers": {
      "get": {
        "tags": [
          "Customers"
        ],
        "summary": "List customers",
        "description": "Your organisation's active customers, in alphabetical order.",
        "operationId": "listCustomers",
        "parameters": [
          {
            "name": "page",
            "in": "query",
            "description": "The page to return, from 1. A smaller value is read as 1.",
            "schema": {
              "minimum": 1,
              "type": "integer",
              "format": "int32",
              "default": 1
            }
          },
          {
            "name": "pageSize",
            "in": "query",
            "description": "How many items per page, from 1 to 200. A value outside the range is held to it.",
            "schema": {
              "maximum": 200,
              "minimum": 1,
              "type": "integer",
              "format": "int32",
              "default": 20
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A page of results.",
            "content": {
              "text/plain": {
                "schema": {
                  "$ref": "#/components/schemas/PublicCustomerPage"
                }
              },
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicCustomerPage"
                },
                "example": {
                  "total": 12,
                  "page": 1,
                  "pageSize": 20,
                  "items": [
                    {
                      "id": "b7d5e0c4-9a21-4c3e-8f0a-52e6d1a94c70",
                      "name": "BCX",
                      "email": "ap@bcx.example",
                      "country": "South Africa"
                    },
                    {
                      "id": "0c6a8f52-71de-4a49-b3b8-1d9c4e27f605",
                      "name": "Karoo Fresh Produce",
                      "email": null,
                      "country": "South Africa"
                    }
                  ]
                }
              },
              "text/json": {
                "schema": {
                  "$ref": "#/components/schemas/PublicCustomerPage"
                }
              }
            }
          },
          "400": {
            "description": "A query parameter could not be read.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          },
          "401": {
            "description": "The API key is missing, unknown or revoked, or the organisation is suspended. No body."
          },
          "403": {
            "description": "The organisation is not on the Enterprise plan. No body."
          },
          "429": {
            "description": "More than 600 requests in a minute from this IP address. No body.",
            "headers": {
              "Retry-After": {
                "description": "Seconds to wait before trying again.",
                "schema": {
                  "type": "integer"
                }
              }
            }
          },
          "500": {
            "description": "Something went wrong on our side.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ProblemDetails"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "InvoiceCreatedData": {
        "type": "object",
        "properties": {
          "invoiceId": {
            "type": "string",
            "format": "uuid"
          },
          "invoiceNumber": {
            "type": "string",
            "nullable": true
          },
          "customerId": {
            "type": "string",
            "format": "uuid"
          },
          "totalAmount": {
            "type": "number",
            "format": "double"
          },
          "currencyCode": {
            "type": "string",
            "nullable": true
          },
          "status": {
            "type": "string",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "InvoiceCreatedDataWebhookEnvelope": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "nullable": true
          },
          "data": {
            "$ref": "#/components/schemas/InvoiceCreatedData"
          },
          "sentAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false
      },
      "InvoicePaidData": {
        "type": "object",
        "properties": {
          "invoiceId": {
            "type": "string",
            "format": "uuid"
          },
          "invoiceNumber": {
            "type": "string",
            "nullable": true
          },
          "amountPaid": {
            "type": "number",
            "format": "double"
          },
          "paymentDate": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false
      },
      "InvoicePaidDataWebhookEnvelope": {
        "type": "object",
        "properties": {
          "event": {
            "type": "string",
            "nullable": true
          },
          "data": {
            "$ref": "#/components/schemas/InvoicePaidData"
          },
          "sentAt": {
            "type": "string",
            "format": "date-time"
          }
        },
        "additionalProperties": false
      },
      "ProblemDetails": {
        "type": "object",
        "properties": {
          "type": {
            "type": "string",
            "nullable": true
          },
          "title": {
            "type": "string",
            "example": "Unexpected error"
          },
          "status": {
            "type": "integer",
            "format": "int32",
            "example": 500
          },
          "detail": {
            "type": "string",
            "example": "Something went wrong on our side. Please try again, and contact support if it keeps happening."
          },
          "error": {
            "type": "string",
            "description": "The same message as detail."
          },
          "traceId": {
            "type": "string",
            "description": "Quote this to support."
          },
          "errors": {
            "type": "object",
            "additionalProperties": {
              "type": "array",
              "items": {
                "type": "string"
              }
            },
            "description": "Validation problems on a 400, by parameter name."
          }
        },
        "description": "RFC 7807 problem details. Server errors also carry the message in error and a traceId to quote to support."
      },
      "PublicCustomerDto": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "description": "The customer's identifier.",
            "format": "uuid"
          },
          "name": {
            "type": "string",
            "description": "The customer's name.",
            "nullable": true
          },
          "email": {
            "type": "string",
            "description": "The customer's email address, when one is on file.",
            "nullable": true
          },
          "country": {
            "type": "string",
            "description": "The customer's country, when one is on file.",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "PublicCustomerPage": {
        "type": "object",
        "properties": {
          "total": {
            "type": "integer",
            "description": "Customers across all pages.",
            "format": "int32"
          },
          "page": {
            "type": "integer",
            "description": "The page returned.",
            "format": "int32"
          },
          "pageSize": {
            "type": "integer",
            "description": "Items per page.",
            "format": "int32"
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicCustomerDto"
            },
            "description": "This page's customers.",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "PublicInvoiceDto": {
        "type": "object",
        "properties": {
          "invoiceNumber": {
            "type": "string",
            "description": "The number printed on the invoice.",
            "nullable": true
          },
          "customerName": {
            "type": "string",
            "description": "The customer's name as it was when the invoice was created.",
            "nullable": true
          },
          "invoiceDate": {
            "type": "string",
            "description": "The invoice date (UTC).",
            "format": "date-time"
          },
          "totalAmount": {
            "type": "number",
            "description": "The total including VAT, in the invoice's currency.",
            "format": "double"
          },
          "currencyCode": {
            "type": "string",
            "description": "ISO 4217 currency code of the invoice.",
            "nullable": true
          },
          "status": {
            "enum": [
              "Draft",
              "Issued",
              "Approved",
              "Posted",
              "Paid",
              "Cancelled",
              "PartiallyPaid"
            ],
            "type": "string",
            "description": "Draft (a quotation, or an invoice not yet issued), Issued, Approved, Posted (due), PartiallyPaid, Paid, or Cancelled.",
            "nullable": true
          }
        },
        "additionalProperties": false
      },
      "PublicInvoicePage": {
        "type": "object",
        "properties": {
          "total": {
            "type": "integer",
            "description": "Invoices across all pages.",
            "format": "int32"
          },
          "page": {
            "type": "integer",
            "description": "The page returned.",
            "format": "int32"
          },
          "pageSize": {
            "type": "integer",
            "description": "Items per page.",
            "format": "int32"
          },
          "items": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PublicInvoiceDto"
            },
            "description": "This page's invoices.",
            "nullable": true
          }
        },
        "additionalProperties": false
      }
    },
    "securitySchemes": {
      "ApiKeyAuth": {
        "type": "apiKey",
        "description": "An API key created under Settings > API Keys (Enterprise plan). It starts with aur_live_ and is shown once, when it is created. A revoked key, or the key of a suspended organisation, stops working immediately.",
        "name": "X-Api-Key",
        "in": "header"
      }
    }
  },
  "security": [
    {
      "ApiKeyAuth": [ ]
    }
  ],
  "tags": [
    {
      "name": "Invoices"
    },
    {
      "name": "Customers"
    }
  ],
  "x-webhooks": {
    "description": "Instead of polling, register an HTTPS endpoint under Settings > Webhooks (organisation administrators, Enterprise plan) and Ayrexa sends it a signed POST when something happens.",
    "events": [
      {
        "name": "invoice.created",
        "description": "Sent right after an invoice or quotation is created, copied or imported.",
        "dataSchema": "InvoiceCreatedData",
        "example": {
          "event": "invoice.created",
          "data": {
            "invoiceId": "3f1c1b6e-2d4a-4f6e-9a57-8c1d2e0b7a11",
            "invoiceNumber": "INV-0021",
            "customerId": "b7d5e0c4-9a21-4c3e-8f0a-52e6d1a94c70",
            "totalAmount": 11500,
            "currencyCode": "ZAR",
            "status": "Draft"
          },
          "sentAt": "2026-11-11T08:30:00Z"
        }
      },
      {
        "name": "invoice.paid",
        "description": "Sent once an invoice is settled in full: by a payment recorded in Ayrexa, a credit note applied to it, or a bank payment the customer made online.",
        "dataSchema": "InvoicePaidData",
        "example": {
          "event": "invoice.paid",
          "data": {
            "invoiceId": "3f1c1b6e-2d4a-4f6e-9a57-8c1d2e0b7a11",
            "invoiceNumber": "INV-0021",
            "amountPaid": 11500,
            "paymentDate": "2026-11-12T00:00:00Z"
          },
          "sentAt": "2026-11-12T09:15:00Z"
        }
      }
    ],
    "request": {
      "method": "POST",
      "contentType": "application/json",
      "headers": [
        {
          "name": "X-Ayrexa-Signature",
          "description": "sha256= followed by the lowercase hexadecimal HMAC-SHA256 of the raw request body, keyed with your endpoint's signing secret."
        },
        {
          "name": "X-Ayrexa-Event",
          "description": "The event name, the same as the event field in the body."
        }
      ],
      "body": "{ \"event\": string, \"data\": object, \"sentAt\": ISO 8601 UTC timestamp }"
    },
    "signature": {
      "algorithm": "HMAC-SHA256",
      "key": "The endpoint's signing secret exactly as it was shown when you created the endpoint, starting whsec_, taken as UTF-8 text. It is shown once.",
      "message": "The raw request body, byte for byte as received. Verify before you parse it: re-serialising JSON changes the bytes.",
      "header": "X-Ayrexa-Signature",
      "format": "sha256=<lowercase hex digest>",
      "comparison": "Compare in constant time. A request whose signature does not match was not sent by Ayrexa: answer 401 and ignore it.",
      "samples": [
        {
          "language": "javascript",
          "label": "Node.js (Express)",
          "code": "import crypto from 'node:crypto';\nimport express from 'express';\n\nconst SECRET = process.env.AYREXA_WEBHOOK_SECRET; // \"whsec_...\" exactly as shown when you created the endpoint\nconst app = express();\n\n// The signature covers the exact bytes Ayrexa sent, so read the raw body. Do not re-serialise parsed JSON.\napp.post('/ayrexa-webhook', express.raw({ type: 'application/json' }), (req, res) => {\n  const received = Buffer.from(req.get('X-Ayrexa-Signature') ?? '');\n  const expected = Buffer.from(\n    'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex'));\n\n  if (received.length !== expected.length || !crypto.timingSafeEqual(received, expected)) {\n    return res.sendStatus(401);\n  }\n\n  const delivery = JSON.parse(req.body.toString('utf8'));\n  // delivery.event, delivery.data, delivery.sentAt\n  res.sendStatus(200);\n});"
        },
        {
          "language": "csharp",
          "label": "C# (ASP.NET Core)",
          "code": "using System.Security.Cryptography;\nusing System.Text;\nusing System.Text.Json;\n\napp.MapPost(\"/ayrexa-webhook\", async (HttpRequest request) =>\n{\n    // The signature covers the exact bytes Ayrexa sent, so read the raw body.\n    using var buffer = new MemoryStream();\n    await request.Body.CopyToAsync(buffer);\n    var body = buffer.ToArray();\n\n    var secret = Environment.GetEnvironmentVariable(\"AYREXA_WEBHOOK_SECRET\")!; // \"whsec_...\" exactly as shown\n    var expected = \"sha256=\" + Convert.ToHexString(HMACSHA256.HashData(Encoding.UTF8.GetBytes(secret), body)).ToLowerInvariant();\n    var received = request.Headers[\"X-Ayrexa-Signature\"].ToString();\n\n    if (!CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(received), Encoding.UTF8.GetBytes(expected)))\n        return Results.Unauthorized();\n\n    var delivery = JsonDocument.Parse(body).RootElement; // event, data, sentAt\n    return Results.Ok();\n});"
        },
        {
          "language": "python",
          "label": "Python (Flask)",
          "code": "import hashlib\nimport hmac\nimport os\n\nfrom flask import Flask, abort, request\n\napp = Flask(__name__)\nSECRET = os.environ[\"AYREXA_WEBHOOK_SECRET\"].encode()  # \"whsec_...\" exactly as shown when you created the endpoint\n\n\n@app.post(\"/ayrexa-webhook\")\ndef ayrexa_webhook():\n    body = request.get_data()  # the exact bytes Ayrexa sent\n    expected = \"sha256=\" + hmac.new(SECRET, body, hashlib.sha256).hexdigest()\n    received = request.headers.get(\"X-Ayrexa-Signature\", \"\")\n\n    if not hmac.compare_digest(received.encode(), expected.encode()):\n        abort(401)\n\n    delivery = request.get_json()  # delivery[\"event\"], delivery[\"data\"], delivery[\"sentAt\"]\n    return \"\", 200"
        }
      ]
    },
    "delivery": {
      "success": "Any 2xx answer. Answer quickly and do the work afterwards.",
      "timing": "Deliveries are queued and sent by a sweep that runs about every two minutes, so allow a few minutes for one to arrive.",
      "retries": {
        "attempts": 5,
        "waits": [
          "1 minute",
          "5 minutes",
          "30 minutes",
          "2 hours"
        ],
        "summary": "Up to 5 attempts in all. After a failed attempt (any answer that is not 2xx, a timeout or a connection error) the next one is made after 1 minute, then 5 minutes, then 30 minutes, then 2 hours; after the fifth failure the delivery is given up."
      },
      "replay": "A retry sends the original body again, with the original sentAt, so sentAt is when the event happened, not when it was delivered. Deliveries can arrive more than once and out of order: make your handler idempotent, for example by remembering the event and data.invoiceId pairs you have handled."
    }
  },
  "x-authentication": {
    "header": "X-Api-Key",
    "keyFormat": "aur_live_ followed by 48 lowercase hexadecimal characters",
    "howToGetOne": "Sign in as an organisation administrator and open Settings > API Keys. API access needs the Enterprise plan.",
    "storage": "Only a hash of the key is stored, so it cannot be shown again after it is created. Create a new key if you lose it, and revoke any key you no longer use.",
    "keepItSecret": "Send it from your server, never from a browser or a mobile app, and never put it in a URL."
  },
  "x-pagination": {
    "pageParameter": "page",
    "pageSizeParameter": "pageSize",
    "defaultPageSize": 20,
    "maxPageSize": 200,
    "envelope": "{ total, page, pageSize, items }",
    "note": "Out-of-range values are corrected, not rejected: page below 1 is read as 1 and pageSize is held between 1 and 200. total is the number of items across all pages, so the last page is ceil(total / pageSize)."
  },
  "x-rate-limits": {
    "requestsPerMinute": 600,
    "scope": "Per client IP address, shared with every other request from that address to the Ayrexa API.",
    "exceeded": "429 Too Many Requests with no body and a Retry-After header giving the seconds to wait.",
    "advice": "Wait for Retry-After, then resume. Fetch pages one after another rather than in parallel."
  },
  "x-errors": [
    {
      "status": 400,
      "meaning": "A query parameter could not be read, for example page=abc.",
      "body": "Problem details with an errors object naming each parameter."
    },
    {
      "status": 401,
      "meaning": "The X-Api-Key header is missing, the key is unknown or revoked, or the organisation is suspended.",
      "body": "None."
    },
    {
      "status": 403,
      "meaning": "The key is valid but the organisation is not on the Enterprise plan.",
      "body": "None."
    },
    {
      "status": 429,
      "meaning": "You went over the rate limit.",
      "body": "None. See the Retry-After header."
    },
    {
      "status": 500,
      "meaning": "Something went wrong on our side. Retrying is safe: every endpoint only reads.",
      "body": "Problem details with an error message and a traceId."
    }
  ],
  "x-versioning": {
    "current": "v1",
    "policy": "The version is the second path segment (/api/public/v1). Within a version we only add: new endpoints, new optional query parameters, new response fields and new webhook events. A change that could break a client ships under a new version (for example /api/public/v2) and leaves this one as it is. Ignore response fields you do not recognise."
  },
  "x-changelog": [
    {
      "version": "1.0.0",
      "date": "2026-11-11",
      "changes": [
        "OpenAPI 3 document published at /api/public/openapi.json.",
        "Documented GET /api/public/v1/invoices and GET /api/public/v1/customers, their pagination, errors and rate limit.",
        "Documented the invoice.created and invoice.paid webhooks and how to verify their signature.",
        "The API can now be called at its own address (api.ayrexa.com) as well as on an organisation's address; before, only the latter worked.",
        "API keys of a suspended organisation stop working."
      ]
    }
  ]
}