openapi: 3.1.0
info:
  title: Octane Integration API
  version: "2026-09-01"
  summary: Pull your confirmed fuel transactions and receive them as signed webhooks.
  description: |
    The Octane Integration API lets Octane customers integrate their own systems (ERP, fleet management, accounting)
    with their fuel transaction data.

    **Two channels**

    - **Pull API**: `GET /transactions` returns the customer's `CONFIRMED` and `EXTERNAL` transactions for a date range,
      with optional filters and cursor-based pagination.
    - **Webhooks**: Octane `POST`s a signed event to a URL you provide whenever a transaction is confirmed or an external
      transaction is recorded. See the `webhooks` section.

    **Access**

    Every request carries a static API key in the `X-API-Key` header. A key belongs to one customer account and covers all of
    its corporate groups and corporates. Production keys are pinned to an IP allow-list and limited to 60 requests per minute.

    [Full guides and code samples](/docs)
  contact:
    name: Your Octane account manager
    url: https://octane-tech.io/
  x-logo:
    altText: Octane

servers:
  - url: https://prod-app.octanetech-api.com/api/v1/integration
    description: Production (keys prefixed `oct_live_`)

security:
  - ApiKey: []

tags:
  - name: Transactions
    description: Read your confirmed and external fuel transactions.
  - name: Webhooks
    description: Events Octane sends to your endpoint. Documented as OpenAPI webhooks; you implement the receiver.

paths:
  /transactions:
    get:
      tags: [Transactions]
      operationId: listTransactions
      summary: List transactions
      description: |
        Returns the caller's `CONFIRMED` and `EXTERNAL` transactions whose `created_at` falls within `from` and `to`,
        newest first, using cursor-based pagination.

        - `PENDING`, `VOID` and `CANCELED` transactions are never returned. A transaction voided after you fetched it disappears
          from later results for the same range.
        - Refund transactions are returned with `correction_reference_id` set to the original transaction.
        - Manual balance adjustments are never returned.
        - The customer account is derived from the API key. `corporate_id` and `corporate_group_id` must belong to it.
      parameters:
        - name: from
          in: query
          required: true
          description: Start of the range on `created_at` (inclusive), ISO 8601.
          schema:
            type: string
            format: date-time
          example: "2026-09-01T00:00:00Z"
        - name: to
          in: query
          required: true
          description: End of the range on `created_at`, ISO 8601. The range may span at most 31 days.
          schema:
            type: string
            format: date-time
          example: "2026-09-30T23:59:59Z"
        - name: corporate_group_id
          in: query
          required: false
          description: Restrict results to one corporate group. Must belong to the caller's account.
          schema:
            type: integer
          example: 9
        - name: corporate_id
          in: query
          required: false
          description: |
            Restrict results to one or more corporates, as a single integer or a comma-separated list.
            Must belong to the caller's account (and to `corporate_group_id`, if given).
          schema:
            type: string
            pattern: '^\d+(,\d+)*$'
          example: "67,68"
        - name: status
          in: query
          required: false
          description: Filter by status. Both statuses are returned when omitted. Comma-separated list accepted.
          schema:
            type: array
            items:
              $ref: '#/components/schemas/TransactionStatus'
          style: form
          explode: false
          example: [CONFIRMED]
        - name: limit
          in: query
          required: false
          description: Page size.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: cursor
          in: query
          required: false
          description: Opaque cursor from the previous page's `pagination.next_cursor`. Pass the same filters with it.
          schema:
            type: string
      responses:
        "200":
          description: A page of transactions.
          headers:
            X-Access-Expires-At:
              description: When the caller's access expires (end of the current charging profile), if it has an end date.
              schema:
                type: string
                format: date-time
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransactionListResponse'
              examples:
                confirmed:
                  $ref: '#/components/examples/TransactionListConfirmed'
        "400":
          $ref: '#/components/responses/ValidationFailed'
        "401":
          $ref: '#/components/responses/InvalidApiKey'
        "403":
          $ref: '#/components/responses/Forbidden'
        "404":
          $ref: '#/components/responses/NotFound'
        "429":
          $ref: '#/components/responses/RateLimited'

webhooks:
  transaction.confirmed:
    post:
      tags: [Webhooks]
      operationId: webhookTransactionConfirmed
      summary: transaction.confirmed
      description: |
        Sent once when a transaction becomes `CONFIRMED`, including refund transactions (duplicate, variance, dispute).
        Only transactions created after the webhook URL was set are pushed.

        The request is signed; verify `X-Octane-Signature` before processing. Respond with any `2xx` within 500 ms; acknowledge first and process asynchronously.
      parameters:
        - $ref: '#/components/parameters/WebhookEventHeader'
        - $ref: '#/components/parameters/WebhookDeliveryHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
            examples:
              confirmed:
                $ref: '#/components/examples/WebhookConfirmed'
      responses:
        "2xx":
          description: Acknowledged. Any 2xx status is treated as success; the body is ignored.
        default:
          description: Treated as a failure and retried (3 attempts in total).
  transaction.external:
    post:
      tags: [Webhooks]
      operationId: webhookTransactionExternal
      summary: transaction.external
      description: |
        Sent once when an `EXTERNAL` transaction (a station outside the Octane network) is recorded.
        Same envelope as `transaction.confirmed`; `data.transaction.status` is `EXTERNAL`, `station.is_external` is `true`,
        `station.id` and `station.provider` are `null`, and `fees` are sent as recorded.
      parameters:
        - $ref: '#/components/parameters/WebhookEventHeader'
        - $ref: '#/components/parameters/WebhookDeliveryHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        "2xx":
          description: Acknowledged.
        default:
          description: Treated as a failure and retried.
  webhook.test:
    post:
      tags: [Webhooks]
      operationId: webhookTest
      summary: webhook.test
      description: |
        Sent on request by Octane so you can verify connectivity and signature verification. Same envelope, `type` is
        `webhook.test`. Verify the signature and return 2xx; do not treat `data` as a real transaction.
      parameters:
        - $ref: '#/components/parameters/WebhookEventHeader'
        - $ref: '#/components/parameters/WebhookDeliveryHeader'
        - $ref: '#/components/parameters/WebhookSignatureHeader'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookEvent'
      responses:
        "2xx":
          description: Acknowledged.

components:
  securitySchemes:
    ApiKey:
      type: apiKey
      in: header
      name: X-API-Key
      description: |
        Static API key issued by Octane, formatted `oct_live_<prefix>_<secret>` (production) or `oct_test_<prefix>_<secret>` (staging).
        The key never expires on its own and its value never changes; Octane switches it off and issues a new one if it is compromised.

  parameters:
    WebhookEventHeader:
      name: X-Octane-Event
      in: header
      required: true
      description: The event type, same as `type` in the body.
      schema:
        $ref: '#/components/schemas/WebhookEventType'
    WebhookDeliveryHeader:
      name: X-Octane-Delivery
      in: header
      required: true
      description: The delivery id, same as `id` in the body. Deterministic per transaction and key; use it to de-duplicate.
      schema:
        type: string
        format: uuid
    WebhookSignatureHeader:
      name: X-Octane-Signature
      in: header
      required: true
      description: |
        `t=<unix timestamp>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>`. Verify with your webhook secret using a
        constant-time comparison and reject stale timestamps (a 5-minute tolerance is recommended).
      schema:
        type: string
      example: "t=1758358803,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd"

  responses:
    ValidationFailed:
      description: A parameter is missing, malformed or out of range.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: VALIDATION_FAILED
              message: Validation failed
              request_id: 8f3c2a1e-4b6d-4e7f-9a0b-1c2d3e4f5a6b
              fields:
                to: range exceeds 31 days
    InvalidApiKey:
      description: The API key is missing, unknown or switched off.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: INVALID_API_KEY
              message: Invalid API key
              request_id: 8f3c2a1e-4b6d-4e7f-9a0b-1c2d3e4f5a6b
    Forbidden:
      description: |
        Access denied. `IP_NOT_ALLOWED` is returned at the edge without a `request_id`. The other codes come from the API:
        `API_INTEGRATION_NOT_ENABLED` (integration switched off on your account), `NO_ACTIVE_CHARGING_PROFILE`
        (no active, unexpired charging profile), `EDGE_NOT_ENFORCED` (Octane-side configuration issue; contact support).
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            ipNotAllowed:
              summary: IP not on the allow-list
              value:
                error:
                  code: IP_NOT_ALLOWED
            notEnabled:
              summary: Integration not enabled
              value:
                error:
                  code: API_INTEGRATION_NOT_ENABLED
                  message: API integration is not enabled for this customer
                  request_id: 8f3c2a1e-4b6d-4e7f-9a0b-1c2d3e4f5a6b
            noProfile:
              summary: No active charging profile
              value:
                error:
                  code: NO_ACTIVE_CHARGING_PROFILE
                  message: Customer has no active charging profile
                  request_id: 8f3c2a1e-4b6d-4e7f-9a0b-1c2d3e4f5a6b
    NotFound:
      description: A `corporate_id` or `corporate_group_id` does not belong to the caller's account.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            corporate:
              value:
                error:
                  code: CORPORATE_NOT_FOUND
                  message: Corporate 999 not found
                  request_id: 8f3c2a1e-4b6d-4e7f-9a0b-1c2d3e4f5a6b
            group:
              value:
                error:
                  code: CORPORATE_GROUP_NOT_FOUND
                  message: Corporate group 999 not found
                  request_id: 8f3c2a1e-4b6d-4e7f-9a0b-1c2d3e4f5a6b
    RateLimited:
      description: More than 60 requests in the current 60-second window. Wait `Retry-After` seconds.
      headers:
        Retry-After:
          description: Seconds to wait before retrying.
          schema:
            type: integer
            example: 60
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          example:
            error:
              code: RATE_LIMITED

  schemas:
    TransactionStatus:
      type: string
      enum: [CONFIRMED, EXTERNAL]
      description: Only these two statuses are ever returned or pushed.

    ErrorCode:
      type: string
      enum:
        - VALIDATION_FAILED
        - INVALID_API_KEY
        - IP_NOT_ALLOWED
        - API_INTEGRATION_NOT_ENABLED
        - NO_ACTIVE_CHARGING_PROFILE
        - EDGE_NOT_ENFORCED
        - CORPORATE_NOT_FOUND
        - CORPORATE_GROUP_NOT_FOUND
        - RATE_LIMITED

    ErrorResponse:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code]
          properties:
            code:
              $ref: '#/components/schemas/ErrorCode'
            message:
              type: string
              description: Human-readable summary. May change; do not parse it.
            request_id:
              type: string
              description: Quote this when contacting support. Absent on edge responses (`IP_NOT_ALLOWED`, `RATE_LIMITED`).
            fields:
              type: object
              description: Only on `VALIDATION_FAILED`. Maps a parameter name to the problem with it.
              additionalProperties:
                type: string

    Pagination:
      type: object
      required: [limit, has_more, next_cursor]
      properties:
        limit:
          type: integer
          description: The page size that was applied.
        has_more:
          type: boolean
        next_cursor:
          type: [string, "null"]
          description: Opaque cursor for the next page. `null` when `has_more` is false.

    TransactionListResponse:
      type: object
      required: [data, pagination]
      properties:
        data:
          type: array
          items:
            $ref: '#/components/schemas/Transaction'
        pagination:
          $ref: '#/components/schemas/Pagination'

    NamedRef:
      type: object
      required: [id, name]
      properties:
        id:
          type: integer
        name:
          type: string

    Transaction:
      type: object
      description: A fuel transaction. Identical in the Pull API and in webhook payloads.
      required:
        - id
        - status
        - created_at
        - confirmed_at
        - correction_reference_id
        - corporate
        - corporate_group
        - fuel
        - odometer_reading
        - distance_traveled
        - fuel_consumption
        - station
        - vehicle
        - driver
        - fees
        - images
      properties:
        id:
          type: integer
          description: Unique transaction id. Stable across Pull and webhook deliveries.
        status:
          $ref: '#/components/schemas/TransactionStatus'
        created_at:
          type: string
          format: date-time
          description: When the transaction was created. The `from` / `to` filters apply to this field.
        confirmed_at:
          type: [string, "null"]
          format: date-time
        correction_reference_id:
          type: [integer, "null"]
          description: For refund transactions, the id of the original transaction being corrected.
        corporate:
          $ref: '#/components/schemas/NamedRef'
        corporate_group:
          oneOf:
            - $ref: '#/components/schemas/NamedRef'
            - type: "null"
          description: The corporate's group, or `null` when the corporate is not in a group.
        fuel:
          type: object
          required: [type, liters, price_per_liter, amount]
          properties:
            type:
              $ref: '#/components/schemas/NamedRef'
            liters:
              type: number
              description: 2 decimals.
            price_per_liter:
              type: number
              description: 2 decimals.
            amount:
              type: number
              description: Fuel amount before fees, 2 decimals.
        odometer_reading:
          type: [integer, "null"]
        distance_traveled:
          type: [integer, "null"]
          description: Distance since the vehicle's previous transaction, when it can be computed.
        fuel_consumption:
          type: [number, "null"]
          description: Litres per 100 km since the previous transaction, when it can be computed. 2 decimals.
        station:
          type: object
          required: [id, name, provider, is_external]
          properties:
            id:
              type: [integer, "null"]
              description: "`null` for external stations."
            name:
              type: string
              description: For external transactions, the external station name as entered.
            provider:
              oneOf:
                - $ref: '#/components/schemas/NamedRef'
                - type: "null"
              description: "`null` for external stations."
            is_external:
              type: boolean
        vehicle:
          type: object
          required: [id, code, number_plate, chassis_number, brand, model, year, department]
          properties:
            id:
              type: integer
            code:
              type: [string, "null"]
              description: Your internal vehicle code, as configured in Octane.
            number_plate:
              type: [string, "null"]
            chassis_number:
              type: [string, "null"]
            brand:
              type: [string, "null"]
            model:
              type: [string, "null"]
            year:
              type: [integer, "null"]
            department:
              oneOf:
                - $ref: '#/components/schemas/NamedRef'
                - type: "null"
        driver:
          type: object
          required: [id, name]
          properties:
            id:
              type: integer
            name:
              type: string
            mobile_number:
              type: string
              description: Present only when Octane has enabled driver mobile numbers on your key.
        fees:
          $ref: '#/components/schemas/Fees'
        images:
          type: object
          required: [pump, odometer, expires_at]
          properties:
            pump:
              type: [string, "null"]
              format: uri
              description: Presigned URL of the pump photo. `null` when no photo exists.
            odometer:
              type: [string, "null"]
              format: uri
              description: Presigned URL of the odometer photo. `null` when no photo exists.
            expires_at:
              type: string
              format: date-time
              description: When the URLs stop working (1 hour in the Pull API, 24 hours in webhooks).

    Fees:
      type: object
      description: |
        Totals as recorded on the transaction, for `CONFIRMED` and `EXTERNAL` alike. These are the same figures shown
        in the Octane dashboard. Individual fee types are not broken down.
      required:
        - total_fees
        - total_vat
        - total_amount
      properties:
        total_fees:
          type: number
          description: All service fees applied to the transaction, excluding VAT. 2 decimals.
        total_vat:
          type: number
          description: All VAT applied to the transaction. 2 decimals.
        total_amount:
          type: number
          description: Total deducted from your balance (`fuel.amount` + `total_fees` + `total_vat`), 6 decimals, matching your Octane statement exactly.

    WebhookEventType:
      type: string
      enum: [transaction.confirmed, transaction.external, webhook.test]

    WebhookEvent:
      type: object
      required: [id, type, created_at, api_version, data]
      properties:
        id:
          type: string
          format: uuid
          description: Delivery id. Deterministic per transaction and key; every retry or redelivery carries the same id.
        type:
          $ref: '#/components/schemas/WebhookEventType'
        created_at:
          type: string
          format: date-time
        api_version:
          type: string
          example: "2026-09-01"
        data:
          type: object
          properties:
            transaction:
              $ref: '#/components/schemas/Transaction'

  examples:
    TransactionListConfirmed:
      summary: One confirmed transaction
      value:
        data:
          - id: 185432
            status: CONFIRMED
            created_at: "2026-09-20T08:59:36.340Z"
            confirmed_at: "2026-09-20T09:00:02.110Z"
            correction_reference_id: null
            corporate: { id: 67, name: Acme Logistics }
            corporate_group: { id: 9, name: Acme Group }
            fuel:
              type: { id: 2, name: Benzine 92 }
              liters: 18.5
              price_per_liter: 13.75
              amount: 254.38
            odometer_reading: 120450
            distance_traveled: 312
            fuel_consumption: 5.93
            station:
              id: 164
              name: Misr Petroleum - Ring Road
              provider: { id: 7, name: Misr Petroleum }
              is_external: false
            vehicle:
              id: 4
              code: V-004
              number_plate: ABC 1234
              chassis_number: JTDBR32E720123456
              brand: Toyota
              model: Hilux
              year: 2021
              department: { id: 3, name: Distribution }
            driver: { id: 31, name: Ahmed Mostafa }
            fees:
              total_fees: 1.06
              total_vat: 0.13
              total_amount: 255.435
            images:
              pump: https://storage.example/pump.jpg?X-Amz-Expires=3600
              odometer: https://storage.example/odometer.jpg?X-Amz-Expires=3600
              expires_at: "2026-09-20T10:00:02Z"
        pagination:
          limit: 50
          has_more: false
          next_cursor: null
    WebhookConfirmed:
      summary: transaction.confirmed event
      value:
        id: 5f0c2e7a-0d3f-5c1a-9b8e-2a4c6d8e0f13
        type: transaction.confirmed
        created_at: "2026-09-20T09:00:03Z"
        api_version: "2026-09-01"
        data:
          transaction:
            id: 185432
            status: CONFIRMED
            created_at: "2026-09-20T08:59:36.340Z"
            confirmed_at: "2026-09-20T09:00:02.110Z"
            correction_reference_id: null
            corporate: { id: 67, name: Acme Logistics }
            corporate_group: { id: 9, name: Acme Group }
            fuel:
              type: { id: 2, name: Benzine 92 }
              liters: 18.5
              price_per_liter: 13.75
              amount: 254.38
            odometer_reading: 120450
            distance_traveled: 312
            fuel_consumption: 5.93
            station:
              id: 164
              name: Misr Petroleum - Ring Road
              provider: { id: 7, name: Misr Petroleum }
              is_external: false
            vehicle:
              id: 4
              code: V-004
              number_plate: ABC 1234
              chassis_number: JTDBR32E720123456
              brand: Toyota
              model: Hilux
              year: 2021
              department: { id: 3, name: Distribution }
            driver: { id: 31, name: Ahmed Mostafa }
            fees:
              total_fees: 1.06
              total_vat: 0.13
              total_amount: 255.435
            images:
              pump: https://storage.example/pump.jpg?X-Amz-Expires=86400
              odometer: https://storage.example/odometer.jpg?X-Amz-Expires=86400
              expires_at: "2026-09-21T09:00:03Z"
