# Authentication and access (/docs/authentication) Send your API key in the `X-API-Key` header on every request. That is the whole authentication model: no login, no tokens, no refresh. A request is accepted only when the key is active, API integration is enabled on your account, the account has an active charging profile, and the request comes from an allowed IP address. ## The API key [#the-api-key] Every request to the Integration API carries a static API key in the `X-API-Key` header: ```http GET /api/v1/integration/transactions?from=...&to=... HTTP/1.1 Host: prod-app.octanetech-api.com X-API-Key: oct_live_a1b2c3d4_Gk7fP2xQ9vLm3nRt8wYb5cHj6sDz1eAu4iFo0pNq ``` | Property | Detail | | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | | Format | `oct_live__` in production, `oct_test__` on staging | | Lifetime | The key does not expire on its own. It stops working only when Octane switches it off, or when your account loses access (see below). | | Rotation | The key's value never changes. If a key is compromised, Octane switches it off and issues a new key with a new prefix. | | Storage on Octane's side | Only a SHA-256 hash of the key is stored. Octane cannot recover a lost key; a new one is issued instead. | | Scope | One customer account, including **all** of its corporate groups and corporates. | Never embed the key in a mobile app, browser code or a public repository. Send it only over HTTPS, and only from the IP addresses on your allow-list. ### The key prefix [#the-key-prefix] The `` segment (for example `a1b2c3d4`) is not secret. Octane uses it to identify your key in logs and in support conversations. Quote it when you contact Octane about a key; never send the full key. ## What can a key access? [#what-can-a-key-access] The customer account is always derived from the key, **never** from request parameters. A key can only read its own customer's transactions. If you filter by `corporate_id` or `corporate_group_id`, the values must belong to your account; anything else returns `404`. If your organisation has several customer accounts with Octane, each one needs its own key. ## When is a request accepted? [#when-is-a-request-accepted] A request succeeds only when all of the following are true, in this order: 1. **The request comes from an allowed IP address.** Otherwise `403 IP_NOT_ALLOWED`. See [Network and rate limits](/docs/network-and-rate-limits). 2. **The key is within its rate limit.** Otherwise `429 RATE_LIMITED`. 3. **The key is valid and active.** Otherwise `401 INVALID_API_KEY`. The same code is returned whether the key is unknown or switched off. 4. **API integration is enabled on your account.** Otherwise `403 API_INTEGRATION_NOT_ENABLED`. 5. **Your account has an active, unexpired charging profile.** Otherwise `403 NO_ACTIVE_CHARGING_PROFILE`. Rules 4 and 5 also decide whether webhooks are sent: if either fails, deliveries stop until access is restored. ### Access expiry [#access-expiry] When your charging profile has an end date, successful responses include an `X-Access-Expires-At` header with that date in ISO 8601. Access ends at exactly that moment unless the profile is renewed. Monitor this header and renew ahead of time to avoid an interruption. ```http HTTP/1.1 200 OK Content-Type: application/json X-Access-Expires-At: 2026-12-31T21:59:59Z ``` ## What do the 401 and 403 codes mean? [#what-do-the-401-and-403-codes-mean] | Status | Code | What to do | | ------ | ----------------------------- | ----------------------------------------------------------------------------------------------------- | | `401` | `INVALID_API_KEY` | Check the header name and value. If the key was switched off, ask Octane for a new one. | | `403` | `API_INTEGRATION_NOT_ENABLED` | Ask your Octane account manager to enable API integration on your account. | | `403` | `NO_ACTIVE_CHARGING_PROFILE` | Your account has no active charging profile, or it has expired. Contact Octane. | | `403` | `IP_NOT_ALLOWED` | The request came from an IP that is not on your allow-list. Send the new IP to Octane. | | `403` | `EDGE_NOT_ENFORCED` | An Octane-side configuration issue. Contact Octane support with your key prefix and the `request_id`. | All errors share the same [error shape](/docs/pull-api/errors). ## Keys and environments [#keys-and-environments] Production keys (`oct_live_`) work only against the production host; staging keys (`oct_test_`) work only on staging. See [Environments](/docs/environments). # Best practices (/docs/best-practices) The most reliable integration uses webhooks for speed and a scheduled Pull API sync for completeness, with everything keyed on the transaction `id`. The sections below cover the details that catch people out. ## Combine webhooks and the Pull API [#combine-webhooks-and-the-pull-api] Use webhooks for near-real-time updates and the Pull API for reconciliation. Neither channel alone covers every case: | Need | Channel | | ---------------------------------------------------------------- | -------- | | Know about a new transaction within seconds | Webhook | | Backfill history, or the period before the webhook URL was set | Pull API | | Recover after your endpoint was down or the webhook was disabled | Pull API | | Detect transactions voided or canceled after delivery | Pull API | | Monthly statement reconciliation | Pull API | A common design: process webhooks as they arrive, and run a scheduled Pull sync (for example every 15 minutes over the last 2 hours, and nightly over the last 7 days) that upserts by transaction `id`. ## How do I detect voids and cancellations? [#how-do-i-detect-voids-and-cancellations] Neither the Pull API nor webhooks report `VOID` or `CANCELED` transactions. A transaction that is voided after you received it **disappears** from Pull API results for its date range. To detect this, periodically re-query recent ranges and compare with what you have stored: 1. Fetch all transactions for the last N days (7 is a reasonable default; extend it if your disputes take longer to resolve). 2. Any transaction you hold with `created_at` in that range that is **absent** from the response has been voided or canceled. Mark it accordingly. 3. Refunds for duplicates, variances and disputes arrive as new `CONFIRMED` transactions with `correction_reference_id` set. Link them to the original. ## Upsert by transaction id [#upsert-by-transaction-id] Treat `id` as the primary key of a transaction on your side. Both channels return the same record for the same id, so an upsert lets you receive an event first and the Pull result later (or the reverse) without conflicts. ## Store amounts precisely [#store-amounts-precisely] * Store `fees.total_amount` with 6 decimal places. It matches your balance deduction exactly and is the figure to reconcile against Octane statements. * Store other amounts with at least 2 decimal places. Use a decimal type, not a floating-point one. ## Handle images promptly [#handle-images-promptly] Image URLs expire after 1 hour (Pull) or 24 hours (webhooks). If you need the photos, download them when you receive the record and store them yourself. Never persist the URL. ## Be tolerant of new fields [#be-tolerant-of-new-fields] New fields may be added to the transaction object and the webhook envelope at any time. Ignore fields you do not recognise, and do not fail on them. ## Respect the rate limit [#respect-the-rate-limit] * Page with `limit=100`. * Back off on `429` using `Retry-After`. * Run one sync worker per key. ## Secure your credentials [#secure-your-credentials] * Keep the API key and webhook secret in a secrets manager. Never commit them. * Send requests only from the IPs on your allow-list; tell Octane before your egress IPs change. * Verify every webhook signature and reject stale timestamps. * If you suspect a key or secret is compromised, contact Octane immediately. The key will be switched off and a new one issued. ## Plan for access expiry [#plan-for-access-expiry] Watch the `X-Access-Expires-At` response header. Renew your charging profile with Octane before that date to avoid `403 NO_ACTIVE_CHARGING_PROFILE` and a pause in webhook deliveries. ## Support [#support] When contacting Octane support, include: * Your **key prefix** (never the full key) * The `request_id` from the error response, if present * The delivery `id` for webhook issues * Timestamps in UTC # Changelog (/docs/changelog) Backwards-incompatible changes are announced here ahead of release. Additive changes (new fields, new event types) may ship without notice; build your parser to ignore unknown fields. ## API version `2026-09-01` [#api-version-2026-09-01] Initial public release. * **Pull API:** `GET /api/v1/integration/transactions` with date-range, corporate-group, corporate and status filters, cursor pagination, up to 100 records per page and a 31-day maximum range. * **Webhooks:** `transaction.confirmed` and `transaction.external` events, HMAC-SHA256 signatures, 3 delivery attempts, automatic disabling after prolonged failure. * **Access:** static API keys in `X-API-Key`, per-key IP allow-list, 60 requests per minute per key. * **Data:** `CONFIRMED` and `EXTERNAL` transactions only. Fees are returned as recorded for both statuses. # Environments (/docs/environments) There is one production base URL, `https://prod-app.octanetech-api.com/api/v1/integration`. The staging host and a test key are provided by your Octane account manager during onboarding. A key works only in the environment it was issued for. ## Base URLs [#base-urls] | Environment | Base URL | Key prefix | | ----------- | -------------------------------------------------------- | ----------- | | Production | `https://prod-app.octanetech-api.com/api/v1/integration` | `oct_live_` | | Staging | Provided by Octane during onboarding | `oct_test_` | All Pull API paths in this documentation are relative to the base URL. For example, the transactions endpoint is: ```text GET https://prod-app.octanetech-api.com/api/v1/integration/transactions ``` ## Differences between environments [#differences-between-environments] | | Production | Staging | | ------------- | -------------------------------- | ----------------------------------------------- | | IP allow-list | Required for every key | Not required; test keys accept any source IP | | Rate limit | 60 requests per minute per key | 60 requests per minute per key | | Data | Your live transactions | Test data | | Webhooks | Delivered to your production URL | Delivered to the URL configured on the test key | Staging keys are only issued on staging and are rejected on production. Ask your Octane account manager for a staging key if you want to build against test data before going live. ## API version [#api-version] Webhook payloads carry an `api_version` field (currently `2026-09-01`). The Pull API response shape and the webhook `data.transaction` object are the same version. Backwards-incompatible changes to either will be announced in the [Changelog](/docs/changelog) before they ship. New fields may be added to responses and payloads at any time without a version change. Build your parser to ignore unknown fields. ## Transport [#transport] * HTTPS only. Plain HTTP is not served. * Responses are UTF-8 JSON with `Content-Type: application/json`. * All timestamps are ISO 8601 in UTC (suffix `Z`). # Introduction (/docs) The **Octane Integration API** gives your systems reliable, near-real-time access to your own fuel transaction data. It is designed for ERP, fleet-management and accounting integrations, and is available to Octane customers on request. ## Two ways to get your data [#two-ways-to-get-your-data] Both channels return the **same transaction record**, so you can start with the Pull API and add webhooks later without changing your data model. ## How access works [#how-access-works] | | | | ------------------ | -------------------------------------------------------------------------------------------------------------------- | | **Authentication** | A static, long-lived API key sent in the `X-API-Key` header. No login or token refresh. | | **Scope** | A key belongs to one customer account and covers all of that customer's corporate groups and corporates. | | **Network** | Production keys are pinned to an allow-list of your IP addresses, and each key is limited to 60 requests per minute. | | **Data** | Only `CONFIRMED` and `EXTERNAL` transactions are ever returned or pushed. | Keys are issued and managed by Octane. To get started, ask your Octane account manager to enable the integration for your account. See the [Quickstart](/docs/quickstart) for the full onboarding checklist. ## What is not included [#what-is-not-included] * Self-service key management: keys are created, switched on and off by Octane. * Notifications for later status changes (for example a transaction being voided after confirmation). See [Detecting voids and cancellations](/docs/best-practices#how-do-i-detect-voids-and-cancellations). * Keys scoped to a single corporate group or corporate. A key always covers the whole customer account. ## Where to go next [#where-to-go-next] # Network and rate limits (/docs/network-and-rate-limits) Two network rules apply to every production key: requests must come from the IP addresses you registered with Octane, and a key may make at most 60 requests per minute. Both are enforced before a request reaches the API, so a blocked request gets a short JSON error and nothing else. ## IP allow-list [#ip-allow-list] Every production key is pinned to an allow-list of IP addresses or CIDR ranges that you supply. Requests from any other source address are rejected: ```json HTTP/1.1 403 Forbidden { "error": { "code": "IP_NOT_ALLOWED" } } ``` * Provide the **public egress** IPs of the systems that call the API. If you run behind a NAT gateway or a cloud provider, that is the gateway's address, not the internal one. * Individual IP addresses and CIDR ranges are accepted. * Changes to the allow-list go through Octane. Send the new addresses to your account manager ahead of any infrastructure change; the update is applied on Octane's side and is not instantaneous. * The allow-list applies only to the Pull API. Webhooks are outbound from Octane and are not affected. * Staging keys are not restricted by IP. The `IP_NOT_ALLOWED` and `RATE_LIMITED` responses are produced at the edge and do not contain a `request_id` or `message`. Quote your key prefix and the timestamp when contacting support about them. ## Rate limit [#rate-limit] Each key may make **60 requests per 60-second window**. The 61st request in a window is rejected until the window resets: ```http HTTP/1.1 429 Too Many Requests Retry-After: 60 Content-Type: application/json { "error": { "code": "RATE_LIMITED" } } ``` * The limit is per key. Do not rely on spreading requests across several IP addresses to get more capacity. * There is no separate daily quota. * Octane can configure a different limit for a key on request, if your integration has a justified need. ### What should I do on a 429? [#what-should-i-do-on-a-429] Respect the `Retry-After` header (seconds) and wait at least that long before retrying. A simple pattern: ```js async function octaneFetch(url, options, attempt = 0) { const res = await fetch(url, options); if (res.status === 429 && attempt < 5) { const retryAfter = Number(res.headers.get('Retry-After') ?? 60); await new Promise((r) => setTimeout(r, retryAfter * 1000)); return octaneFetch(url, options, attempt + 1); } return res; } ``` ### How do I stay under the limit? [#how-do-i-stay-under-the-limit] * Use `limit=100` when paging through history to minimise the number of requests. * Sync on a schedule rather than polling continuously. A sync every 15 minutes over the last hour is a typical pattern. * Prefer webhooks for near-real-time needs and use the Pull API for reconciliation. * Run one sync process at a time per key; parallel workers sharing a key will exhaust the limit quickly. # Errors (/docs/pull-api/errors) Every error is one JSON object with a stable `code` to branch on, a human-readable `message`, and a `request_id` to quote to support. Fix and resend `4xx` errors; retry only `429` after `Retry-After` and `5xx` with backoff. ## Error shape [#error-shape] Every error response is JSON with a single `error` object: ```json { "error": { "code": "VALIDATION_FAILED", "message": "Validation failed", "request_id": "8f3c2a1e-4b6d-4e7f-9a0b-1c2d3e4f5a6b", "fields": { "to": "range exceeds 31 days" } } } ``` | Field | Description | | ------------ | --------------------------------------------------------------------- | | `code` | Stable, machine-readable code. Branch on this, not on `message`. | | `message` | Human-readable summary. May change; do not parse it. | | `request_id` | Identifier of the request. Include it when contacting Octane support. | | `fields` | Only on `VALIDATION_FAILED`: a map of parameter name to problem. | Responses produced at the edge (`IP_NOT_ALLOWED`, `RATE_LIMITED`) contain only `code`. ## Error codes [#error-codes] | Status | Code | Meaning | What to do | | ------ | ----------------------------- | ------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | | `400` | `VALIDATION_FAILED` | A parameter is missing, malformed or out of range. | Read `fields`, fix the request. Do not retry unchanged. | | `401` | `INVALID_API_KEY` | The key is missing, unknown or switched off. | Check the `X-API-Key` header. If the key was switched off, obtain a new key from Octane. Do not retry unchanged. | | `403` | `IP_NOT_ALLOWED` | The source IP is not on the key's allow-list. | Send the IP to Octane to be added. Do not retry until it is. | | `403` | `API_INTEGRATION_NOT_ENABLED` | API integration is switched off on your account. | Contact your Octane account manager. | | `403` | `NO_ACTIVE_CHARGING_PROFILE` | Your account has no active, unexpired charging profile. | Contact your Octane account manager to renew. | | `403` | `EDGE_NOT_ENFORCED` | An Octane-side configuration problem. | Contact Octane support with the `request_id`. | | `404` | `CORPORATE_NOT_FOUND` | A `corporate_id` is not part of your account. | Check the id. | | `404` | `CORPORATE_GROUP_NOT_FOUND` | The `corporate_group_id` is not part of your account. | Check the id. | | `429` | `RATE_LIMITED` | More than 60 requests in the current minute. | Wait for `Retry-After` seconds and retry. See [Rate limit](/docs/network-and-rate-limits#rate-limit). | | `5xx` | | Temporary server error. | Retry with exponential backoff. | ## Which errors should I retry? [#which-errors-should-i-retry] | Status | Retry? | | -------------------------- | --------------------------------------------------------------------------------------- | | `400`, `401`, `403`, `404` | No. Fix the cause first. | | `429` | Yes, after `Retry-After` seconds. | | `5xx`, network timeout | Yes, with exponential backoff and a cap (for example 5 attempts starting at 2 seconds). | Validation errors on the transactions endpoint typically name one of these problems in `fields`: | Parameter | Typical problems | | -------------- | ---------------------------------------------------------------- | | `from`, `to` | missing, not ISO 8601, `to` before `from`, range exceeds 31 days | | `limit` | not an integer, below 1, above 100 | | `status` | value other than `CONFIRMED` or `EXTERNAL` | | `corporate_id` | not an integer or comma-separated list of integers | | `cursor` | malformed | # Pagination (/docs/pull-api/pagination) To page through results, repeat the request with `cursor=` and the same filters until `has_more` is `false`. The cursor is opaque and keyset-based, so new transactions arriving mid-sync never cause skips or duplicates. The transactions endpoint uses **cursor-based (keyset) pagination**. Every response includes a `pagination` object: ```json { "pagination": { "limit": 100, "has_more": true, "next_cursor": "eyJ0IjoiMjAyNi0wOS0yMFQwODo1OTozNi4zNDBaIiwiaWQiOjE4NTQzMn0" } } ``` | Field | Description | | ------------- | ------------------------------------------------------------------ | | `limit` | The page size that was applied. | | `has_more` | `true` if another page exists. | | `next_cursor` | Opaque token for the next page. `null` when `has_more` is `false`. | ## How do I page through results? [#how-do-i-page-through-results] 1. Make the first request with your filters and no `cursor`. 2. While `has_more` is `true`, repeat the request with **the same filters** and `cursor=`. 3. Stop when `has_more` is `false`. Results are ordered newest first by `created_at`, then by `id`. Because the cursor encodes the position of the last record rather than an offset, transactions that arrive while you are paging do not cause records to be skipped or repeated. ## Rules [#rules] * Treat the cursor as opaque. Its format may change; do not parse or construct it. * Always pass the same `from`, `to`, `corporate_id`, `corporate_group_id` and `status` values with every page. A cursor only makes sense for the query that produced it. * Use `limit=100` for bulk syncs to reduce the number of requests against your [rate limit](/docs/network-and-rate-limits). ## Example: fetch a full range [#example-fetch-a-full-range] ```js async function* transactions(apiKey, filters) { const base = 'https://prod-app.octanetech-api.com/api/v1/integration/transactions'; let cursor; do { const url = new URL(base); for (const [k, v] of Object.entries(filters)) url.searchParams.set(k, String(v)); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { 'X-API-Key': apiKey } }); if (!res.ok) throw new Error(`Octane API ${res.status}: ${await res.text()}`); const { data, pagination } = await res.json(); yield* data; cursor = pagination.has_more ? pagination.next_cursor : undefined; } while (cursor); } for await (const tx of transactions(process.env.OCTANE_API_KEY, { from: '2026-09-01T00:00:00Z', to: '2026-09-30T23:59:59Z', })) { console.log(tx.id, tx.status, tx.fees.total_amount); } ``` ```python import os, requests BASE = "https://prod-app.octanetech-api.com/api/v1/integration/transactions" def transactions(api_key, **filters): cursor = None while True: params = {**filters, "limit": 100} if cursor: params["cursor"] = cursor res = requests.get(BASE, headers={"X-API-Key": api_key}, params=params, timeout=30) res.raise_for_status() body = res.json() yield from body["data"] if not body["pagination"]["has_more"]: break cursor = body["pagination"]["next_cursor"] for tx in transactions(os.environ["OCTANE_API_KEY"], **{"from": "2026-09-01T00:00:00Z", "to": "2026-09-30T23:59:59Z"}): print(tx["id"], tx["status"], tx["fees"]["total_amount"]) ``` ## How do I fetch more than 31 days? [#how-do-i-fetch-more-than-31-days] A single request covers at most 31 days. To sync a longer period, split it into consecutive windows and page through each one: ```js function* windows(from, to, days = 31) { let start = new Date(from); const end = new Date(to); while (start < end) { const next = new Date(Math.min(start.getTime() + days * 86_400_000, end.getTime())); yield [start.toISOString(), next.toISOString()]; start = next; } } ``` # List transactions (/docs/pull-api/transactions) ```http GET /api/v1/integration/transactions ``` Returns the caller's `CONFIRMED` and `EXTERNAL` fuel transactions created within a date range, newest first, with cursor-based pagination. The customer account is derived from the API key. ## Query parameters [#query-parameters] | Parameter | Type | Required | Description | | -------------------- | ------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------ | | `from` | ISO 8601 date-time | Yes | Start of the range on `created_at` (inclusive). | | `to` | ISO 8601 date-time | Yes | End of the range on `created_at`. The range may span at most **31 days**. | | `corporate_group_id` | integer | No | Restrict to one corporate group. Must belong to your account. | | `corporate_id` | integer or comma-separated list | No | Restrict to one or more corporates. Must belong to your account, and to the group if `corporate_group_id` is also given. | | `status` | list of `CONFIRMED`, `EXTERNAL` | No | Filter by status. Both are returned when omitted. | | `limit` | integer | No | Page size. Default `50`, maximum `100`. | | `cursor` | string | No | Opaque cursor from the previous page's `pagination.next_cursor`. Repeat the same filters when passing a cursor. | Pass several statuses as a comma-separated list, for example `status=CONFIRMED,EXTERNAL`. ### Which transactions are returned? [#which-transactions-are-returned] * Only `CONFIRMED` and `EXTERNAL` transactions. `PENDING`, `VOID` and `CANCELED` transactions are **never** returned. A transaction that is voided or canceled after you fetched it simply disappears from later results for the same range. See [Detecting voids and cancellations](/docs/best-practices#how-do-i-detect-voids-and-cancellations). * Refund transactions (for duplicates, variances or resolved disputes) **are** returned, with `correction_reference_id` pointing to the original transaction. * Manual balance adjustments made by Octane's accountants are never returned. ## Example request [#example-request] ```bash curl -G "https://prod-app.octanetech-api.com/api/v1/integration/transactions" \ -H "X-API-Key: $OCTANE_API_KEY" \ --data-urlencode "from=2026-09-01T00:00:00Z" \ --data-urlencode "to=2026-09-30T23:59:59Z" \ --data-urlencode "corporate_id=67,68" \ --data-urlencode "status=CONFIRMED" \ --data-urlencode "limit=100" ``` ```js const url = new URL('https://prod-app.octanetech-api.com/api/v1/integration/transactions'); url.searchParams.set('from', '2026-09-01T00:00:00Z'); url.searchParams.set('to', '2026-09-30T23:59:59Z'); url.searchParams.set('corporate_id', '67,68'); url.searchParams.set('status', 'CONFIRMED'); url.searchParams.set('limit', '100'); const res = await fetch(url, { headers: { 'X-API-Key': process.env.OCTANE_API_KEY } }); const body = await res.json(); ``` ```python import os, requests res = requests.get( "https://prod-app.octanetech-api.com/api/v1/integration/transactions", headers={"X-API-Key": os.environ["OCTANE_API_KEY"]}, params={ "from": "2026-09-01T00:00:00Z", "to": "2026-09-30T23:59:59Z", "corporate_id": "67,68", "status": "CONFIRMED", "limit": 100, }, timeout=30, ) res.raise_for_status() body = res.json() ``` ```csharp using var http = new HttpClient(); http.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("OCTANE_API_KEY")); var url = "https://prod-app.octanetech-api.com/api/v1/integration/transactions" + "?from=2026-09-01T00:00:00Z&to=2026-09-30T23:59:59Z" + "&corporate_id=67,68&status=CONFIRMED&limit=100"; using var res = await http.GetAsync(url); res.EnsureSuccessStatusCode(); var json = await res.Content.ReadAsStringAsync(); ``` ## Example response [#example-response] ```json { "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.435000 }, "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": 100, "has_more": true, "next_cursor": "eyJ0IjoiMjAyNi0wOS0yMFQwODo1OTozNi4zNDBaIiwiaWQiOjE4NTQzMn0" } } ``` Successful responses may also include the `X-Access-Expires-At` header. See [Access expiry](/docs/authentication#access-expiry). ## The transaction object [#the-transaction-object] ### Top level [#top-level] | Field | Type | Description | | ------------------------- | ------------------------- | ---------------------------------------------------------------------------------------------- | | `id` | integer | Unique transaction id. Stable across Pull and webhook deliveries. | | `status` | `CONFIRMED` or `EXTERNAL` | | | `created_at` | date-time | When the transaction was created. The `from` and `to` filters apply to this field. | | `confirmed_at` | date-time, nullable | When the transaction was confirmed. | | `correction_reference_id` | integer, nullable | For refund transactions, the id of the original transaction being corrected. `null` otherwise. | | `corporate` | object | The corporate the vehicle belongs to. Always present. | | `corporate_group` | object, nullable | The corporate's group. `null` when the corporate is not in a group. | | `fuel` | object | Fuel type, quantity and price. | | `odometer_reading` | integer, nullable | Odometer value captured at the transaction, when available. | | `distance_traveled` | integer, nullable | Distance since the vehicle's previous transaction, when it can be computed. | | `fuel_consumption` | number, nullable | Litres per 100 km since the previous transaction, when it can be computed. | | `station` | object | Where the fuel was dispensed. | | `vehicle` | object | The vehicle that was fuelled. | | `driver` | object | The driver. | | `fees` | object | Total fees, total VAT and the total amount charged. | | `images` | object | Presigned URLs of the pump and odometer photos. | ### `corporate` and `corporate_group` [#corporate-and-corporate_group] | Field | Type | Description | | ------ | ------- | ----------- | | `id` | integer | | | `name` | string | | ### `fuel` [#fuel] | Field | Type | Description | | ----------------- | ------- | ----------------------------------------- | | `type.id` | integer | Fuel type id. | | `type.name` | string | Fuel type name, for example `Benzine 92`. | | `liters` | number | Quantity dispensed, 2 decimals. | | `price_per_liter` | number | Unit price, 2 decimals. | | `amount` | number | Fuel amount before fees, 2 decimals. | ### `station` [#station] | Field | Type | Description | | ------------- | ----------------- | ------------------------------------------------------------------------------------- | | `id` | integer, nullable | Station id. `null` for external stations. | | `name` | string | Station name. For external transactions, the name of the external station as entered. | | `provider` | object, nullable | The station provider (`id`, `name`). `null` for external stations. | | `is_external` | boolean | `true` when the transaction was recorded at a station outside the Octane network. | ### `vehicle` [#vehicle] | Field | Type | Description | | ---------------- | ----------------- | ------------------------------------------------------- | | `id` | integer | | | `code` | string, nullable | Your internal vehicle code, as configured in Octane. | | `number_plate` | string, nullable | | | `chassis_number` | string, nullable | | | `brand`, `model` | string, nullable | | | `year` | integer, nullable | | | `department` | object, nullable | The vehicle's department (`id`, `name`), when assigned. | ### `driver` [#driver] | Field | Type | Description | | --------------- | ------- | -------------------------------------------------------------------------------------------------------------------- | | `id` | integer | | | `name` | string | | | `mobile_number` | string | Present **only** when Octane has enabled driver mobile numbers on your key. Ask your account manager if you need it. | The driver's email address is never returned. ### `fees` [#fees] The same three figures shown in the Octane dashboard's transactions table. | Field | Type | Description | | -------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------ | | `total_fees` | number | All service fees applied to the transaction, excluding VAT. 2 decimals. | | `total_vat` | number | All VAT applied to the transaction. 2 decimals. | | `total_amount` | number | Total deducted from your balance: `fuel.amount` + `total_fees` + `total_vat`. **6 decimals**, so it matches your Octane statement exactly. | Fees are returned exactly as recorded on the transaction, for `EXTERNAL` transactions as well as `CONFIRMED` ones. A breakdown of individual fee types is not part of the API. ### `images` [#images] | Field | Type | Description | | ------------ | ---------------------- | ------------------------------------------------------- | | `pump` | string (URL), nullable | Photo of the pump display. `null` when no photo exists. | | `odometer` | string (URL), nullable | Photo of the odometer. `null` when no photo exists. | | `expires_at` | date-time | When the URLs stop working. | Image URLs are **presigned and expire after 1 hour**. Download the images promptly if you need to keep them; do not store the URLs. Re-fetch the transaction to get fresh URLs. ## External transactions [#external-transactions] An `EXTERNAL` transaction was recorded at a station outside the Octane network. It has the same shape as a confirmed transaction, with these differences: * `status` is `EXTERNAL`. * `station.is_external` is `true`, `station.id` and `station.provider` are `null`, and `station.name` is the external station name. * `fees` are returned as recorded, which may be zero or non-zero depending on your agreement. ## Precision rules [#precision-rules] | Field | Precision | | ---------------------------------------------------- | ---------- | | `fuel.liters`, `fuel.price_per_liter`, `fuel.amount` | 2 decimals | | `fees.total_fees`, `fees.total_vat` | 2 decimals | | `fees.total_amount` | 6 decimals | | `fuel_consumption` | 2 decimals | ## Errors [#errors] | Status | Code | Cause | | ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `400` | `VALIDATION_FAILED` | Missing or malformed parameter, range over 31 days, `limit` over 100, unknown `status`, invalid `cursor`. The `fields` object names the offending parameter. | | `404` | `CORPORATE_NOT_FOUND` | A `corporate_id` does not belong to your account (or to the given group). | | `404` | `CORPORATE_GROUP_NOT_FOUND` | The `corporate_group_id` does not belong to your account. | Plus the shared authentication and network errors. See [Errors](/docs/pull-api/errors). # Quickstart (/docs/quickstart) You need three things from Octane before your first call: API integration switched on for your account, your egress IP addresses registered, and an API key. After that, one `GET` request returns your transactions and, if you set a webhook URL, new transactions arrive on their own. ### Get the integration enabled [#get-the-integration-enabled] Ask your Octane account manager to enable **API integration** on your account. Access requires that the integration is switched on for your account and that you have an active charging profile with Octane. ### Send your IP addresses [#send-your-ip-addresses] Production keys only accept requests from an allow-list of IP addresses or CIDR ranges that you provide. Send the public egress IPs of the systems that will call the API. The allow-list is applied on Octane's side before your key is switched on, so requests from other addresses are rejected with `403 IP_NOT_ALLOWED`. Optionally, provide an **HTTPS webhook URL** if you want Octane to push new transactions to you. ### Receive your API key [#receive-your-api-key] Octane creates the key and shares it with you **once**, together with your **webhook secret** if you set a webhook URL. Neither value is shown again, so store them in a secrets manager immediately. A key looks like this: ```text oct_live_a1b2c3d4_Gk7fP2xQ9vLm3nRt8wYb5cHj6sDz1eAu4iFo0pNq ``` The `oct_live_` prefix identifies a production key. The `a1b2c3d4` segment is a non-secret **key prefix** that Octane uses to identify your key in support conversations. The rest is the secret. ### Make your first request [#make-your-first-request] Fetch yesterday's confirmed transactions: ```bash curl "https://prod-app.octanetech-api.com/api/v1/integration/transactions?from=2026-09-20T00:00:00Z&to=2026-09-21T00:00:00Z&limit=50" \ -H "X-API-Key: $OCTANE_API_KEY" ``` ```js const params = new URLSearchParams({ from: '2026-09-20T00:00:00Z', to: '2026-09-21T00:00:00Z', limit: '50', }); const res = await fetch( `https://prod-app.octanetech-api.com/api/v1/integration/transactions?${params}`, { headers: { 'X-API-Key': process.env.OCTANE_API_KEY } }, ); if (!res.ok) throw new Error(`Octane API error ${res.status}: ${await res.text()}`); const { data, pagination } = await res.json(); console.log(data.length, 'transactions, more:', pagination.has_more); ``` ```python import os import requests res = requests.get( "https://prod-app.octanetech-api.com/api/v1/integration/transactions", params={"from": "2026-09-20T00:00:00Z", "to": "2026-09-21T00:00:00Z", "limit": 50}, headers={"X-API-Key": os.environ["OCTANE_API_KEY"]}, timeout=30, ) res.raise_for_status() body = res.json() print(len(body["data"]), "transactions, more:", body["pagination"]["has_more"]) ``` A successful response looks like this (trimmed): ```json { "data": [ { "id": 185432, "status": "CONFIRMED", "created_at": "2026-09-20T08:59:36.340Z", "confirmed_at": "2026-09-20T09:00:02.110Z", "corporate": { "id": 67, "name": "Acme Logistics" }, "fuel": { "type": { "id": 2, "name": "Benzine 92" }, "liters": 18.5, "price_per_liter": 13.75, "amount": 254.38 }, "fees": { "total_amount": 255.435000 } } ], "pagination": { "limit": 50, "has_more": false, "next_cursor": null } } ``` See the [Transactions endpoint](/docs/pull-api/transactions) for every field. ### Receive your first webhook (optional) [#receive-your-first-webhook-optional] If you provided a webhook URL, Octane can send a `webhook.test` event on request so you can check connectivity and your signature verification before real traffic arrives. Ask your account manager to trigger it. Your endpoint should: 1. Read the **raw** request body (before JSON parsing). 2. Verify the `X-Octane-Signature` header with your webhook secret. See [Signature verification](/docs/webhooks/signature-verification). 3. Respond with any `2xx` status within **500 ms**. Acknowledge first, process afterwards. Only transactions created **after** the webhook URL was set are pushed, so use the Pull API to backfill history. ## Onboarding checklist [#onboarding-checklist] * [ ] API integration enabled on your Octane account * [ ] Active charging profile in place * [ ] Egress IP addresses sent to Octane * [ ] API key stored in a secrets manager * [ ] Webhook URL (HTTPS on port 443, publicly reachable) and secret stored, if using webhooks * [ ] Retry with backoff on `429`, and cursor pagination implemented # Delivery and retries (/docs/webhooks/delivery-and-retries) Octane makes up to 3 attempts per delivery, each with a 500 ms timeout, and switches the webhook off after 50 consecutive failures or 3 days of failure. The delivery id is the same on every attempt, so de-duplicate on it. ## A single delivery [#a-single-delivery] For each transaction and each of your keys that has a webhook URL, Octane makes one delivery: | Rule | Value | | --------- | ---------------------------------------------------------------------------- | | Method | `POST`, `Content-Type: application/json` | | Timeout | **500 ms** from connection to complete response | | Success | Any `2xx` status. The body is ignored. | | Failure | Any non-`2xx` status, a timeout, a TLS or connection error, or a DNS failure | | Redirects | Not followed; a `3xx` counts as a failure | The timeout is deliberately short. Your handler must not do any work before responding: no database writes, no downstream API calls, no image downloads. The only things to do inline are verifying the signature and handing the raw event to a queue or background job, then returning `2xx`. Cold starts, TLS handshakes and slow DNS all count against the budget. Keep the endpoint warm, terminate TLS close to your application, and avoid serverless platforms with multi-second cold starts for this route. If you see `TIMEOUT` failures on Octane's side, this is the first place to look. ## How often does Octane retry? [#how-often-does-octane-retry] A failed delivery is retried up to **3 attempts in total**, spaced roughly as follows: | Attempt | Approximate delay after the previous attempt | | ------- | -------------------------------------------- | | 1 | immediately | | 2 | 30 seconds | | 3 | 2.5 minutes | Every attempt sends the **same** body and the same delivery `id`; only the signature timestamp and the image URLs (re-signed, 24-hour validity) change. After the third failed attempt the delivery is recorded as failed on Octane's side, together with the response your endpoint returned. Octane can **redeliver** a failed event on request. A redelivery carries the original `id`. ## When is a webhook disabled? [#when-is-a-webhook-disabled] If deliveries keep failing, the webhook is switched off automatically after **50 consecutive failed deliveries** or **3 days** of continuous failure, whichever comes first. You and Octane Operations are notified by email. * Any successful delivery resets the failure counter. * While disabled, no events are sent and none are queued. Use the Pull API to backfill the gap once your endpoint is healthy. * Ask Octane to re-enable the webhook when the endpoint is fixed. ## Idempotency [#idempotency] Retries and redeliveries mean your endpoint **can receive the same event more than once**. The delivery `id` (also in `X-Octane-Delivery`) is deterministic for a given transaction and key, so it is a safe idempotency key: ```js async function handle(event) { const inserted = await db.execute( 'INSERT INTO octane_events (delivery_id, received_at) VALUES ($1, now()) ON CONFLICT DO NOTHING', [event.id], ); if (inserted.rowCount === 0) return; // already processed await processTransaction(event.data.transaction); } ``` Alternatively, upsert on `data.transaction.id`: a transaction is only ever pushed once per key, so a second delivery with the same transaction id is always a retry. ## Are events delivered in order? [#are-events-delivered-in-order] Events are delivered as transactions are confirmed, but ordering across events is **not guaranteed**, especially when retries are involved. Use `data.transaction.created_at` or `confirmed_at` if order matters to you. ## Monitoring on your side [#monitoring-on-your-side] * Alert on a sustained rate of signature failures: it usually means a rotated secret or a misconfigured raw-body handler. * Alert if you have received no events for longer than your usual traffic pattern; the webhook may have been disabled. * Log the delivery `id` with every processed event so support conversations can reference it. * Reconcile daily with the [Pull API](/docs/pull-api/transactions) to catch anything missed while your endpoint was down. # Events and payload (/docs/webhooks/events-and-payload) Each webhook is an HTTPS `POST` with three `X-Octane-*` headers and a JSON envelope whose `data.transaction` is exactly the object the Pull API returns. The envelope `id` is the delivery id, stable across retries. ## The request [#the-request] ```http POST /your/webhook/path HTTP/1.1 Host: erp.example.com Content-Type: application/json User-Agent: Octane-Webhooks/1.0 X-Octane-Event: transaction.confirmed X-Octane-Delivery: 5f0c2e7a-0d3f-5c1a-9b8e-2a4c6d8e0f13 X-Octane-Signature: t=1758358803,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` | Header | Description | | -------------------- | --------------------------------------------------------------------------------------------------------------------- | | `X-Octane-Event` | The event type. Same value as `type` in the body. | | `X-Octane-Delivery` | The delivery id. Same value as `id` in the body. Use it to de-duplicate. | | `X-Octane-Signature` | Timestamp and HMAC-SHA256 signature of the body. See [Signature verification](/docs/webhooks/signature-verification). | | `User-Agent` | Always `Octane-Webhooks/1.0`. | ## The envelope [#the-envelope] ```json { "id": "5f0c2e7a-0d3f-5c1a-9b8e-2a4c6d8e0f13", "type": "transaction.confirmed", "created_at": "2026-09-20T09:00:03Z", "api_version": "2026-09-01", "data": { "transaction": { "...": "see below" } } } ``` | Field | Description | | ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `id` | Delivery id. **Deterministic** for a given transaction and key: every retry or redelivery of the same event carries the same id. Store it and ignore duplicates. | | `type` | `transaction.confirmed`, `transaction.external` or `webhook.test`. | | `created_at` | When the event was created, ISO 8601 UTC. | | `api_version` | Version of the payload schema. Currently `2026-09-01`. | | `data.transaction` | The transaction object. Identical to the [Pull API transaction object](/docs/pull-api/transactions#the-transaction-object). | ## Event types [#event-types] ### `transaction.confirmed` [#transactionconfirmed] Sent when a transaction becomes `CONFIRMED`. `data.transaction.status` is `CONFIRMED`. Refund transactions are included and carry `correction_reference_id`. ```json { "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.435000 }, "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" } } } } ``` ### `transaction.external` [#transactionexternal] Sent when an `EXTERNAL` transaction is recorded. Same envelope and transaction shape; `status` is `EXTERNAL`, `station.is_external` is `true`, `station.id` and `station.provider` are `null`, and `fees` are sent exactly as recorded. ### `webhook.test` [#webhooktest] Sent on request by Octane so you can verify connectivity and your signature check. The envelope is the same and `type` is `webhook.test`. Verify the signature and return `2xx`. Do not treat its `data` as a real transaction. ## How does the payload differ from the Pull API? [#how-does-the-payload-differ-from-the-pull-api] | Topic | Pull API | Webhook | | ---------------------- | ---------------------------- | -------------------------------------------------------------------------------- | | Image URL validity | 1 hour | 24 hours, re-signed on every delivery attempt | | Ordering | Newest first, paginated | One event per delivery, in the order transactions are confirmed (not guaranteed) | | Driver `mobile_number` | Only when enabled on the key | Same | Everything else, including field names, nullability and precision, is identical. # Webhooks overview (/docs/webhooks) Webhooks let Octane notify your system in near real time, instead of you polling the Pull API. Whenever one of your transactions is confirmed, or an external transaction is recorded, Octane sends an HTTPS `POST` to your endpoint with the full transaction record. ## How do webhooks work? [#how-do-webhooks-work] 1. You give Octane an HTTPS URL when your API key is created (or later, on request). Octane generates a **webhook secret** and shares it with you once. 2. When a transaction reaches `CONFIRMED` or `EXTERNAL`, Octane builds the payload and `POST`s it to your URL, signed with the secret. 3. Your endpoint verifies the signature and returns any `2xx` status within **500 ms**. 4. If delivery fails, Octane retries up to 3 times. Persistent failures are logged and, after prolonged failure, the webhook is disabled and you are notified by email. Webhooks fire **once** per transaction, when it becomes `CONFIRMED` or `EXTERNAL`. Later changes (a void or cancellation) are not pushed. Use the Pull API to detect them. See [Best practices](/docs/best-practices#how-do-i-detect-voids-and-cancellations). ## Endpoint requirements [#endpoint-requirements] | Requirement | Detail | | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Scheme and port | `https://` on port 443 only. Plain HTTP and non-standard ports are rejected. | | Reachability | The hostname must resolve to a **public** IP address. Private, loopback, link-local and cloud-metadata ranges are rejected. | | TLS | A valid, publicly trusted certificate. | | Redirects | Not followed. The URL must respond directly. | | Timeout | Respond within **500 ms**, measured from connection to complete response. Return `2xx` immediately, then process the event asynchronously. A slow response counts as a failed delivery. | | Success | Any `2xx` status. The response body is ignored. | One webhook URL is configured per API key. If you need several destinations, ask Octane for a key per destination. ## What triggers a webhook? [#what-triggers-a-webhook] | Event | Sent when | | ----------------------- | ----------------------------------------------------------------------------------------------------------------- | | `transaction.confirmed` | A transaction becomes `CONFIRMED`, including refund transactions for duplicates, variances and resolved disputes. | | `transaction.external` | An `EXTERNAL` transaction (a station outside the Octane network) is recorded. | | `webhook.test` | On request, to verify your endpoint. | Not sent: `PENDING`, `VOID` or `CANCELED` transactions, later status changes of a transaction already delivered, and manual balance adjustments. Only transactions created **after** the webhook URL was set are pushed. Use the Pull API to backfill anything earlier. ## Next steps [#next-steps] # Signature verification (/docs/webhooks/signature-verification) To verify a webhook, compute HMAC-SHA256 over `.` with your webhook secret and compare it, in constant time, with the `v1` value in the `X-Octane-Signature` header. Reject anything that fails, and reject timestamps older than a few minutes. Always verify before acting on the payload. ## The header [#the-header] ```text X-Octane-Signature: t=1758358803,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` | Element | Description | | ------- | ------------------------------------------------------------------------- | | `t` | Unix timestamp (seconds) at which Octane signed the request. | | `v1` | Lower-case hex HMAC-SHA256 of `.` using your webhook secret. | ## Verification steps [#verification-steps] ### Read the raw body [#read-the-raw-body] Use the exact bytes Octane sent. Do not re-serialise parsed JSON: any change in whitespace or key order changes the signature. ### Parse the header [#parse-the-header] Split on `,`, then each part on `=`, to get `t` and `v1`. ### Check the timestamp [#check-the-timestamp] Reject the request if `t` is too far from your current time. A tolerance of **5 minutes** is recommended. This limits replay of captured requests. ### Compute the expected signature [#compute-the-expected-signature] `signed_payload = t + "." + raw_body`, then `HMAC_SHA256(secret, signed_payload)` as lower-case hex. ### Compare in constant time [#compare-in-constant-time] Compare your value with `v1` using a constant-time comparison function. A plain string comparison leaks timing information. ## Code samples [#code-samples] Each sample exposes a `verify(rawBody, signatureHeader, secret)` function and shows how to read the raw body in a common framework. ```js const crypto = require('node:crypto'); const TOLERANCE_SECONDS = 300; function verifyOctaneSignature(rawBody, header, secret) { if (typeof header !== 'string') return false; const parts = Object.fromEntries( header.split(',').map((kv) => kv.split('=').map((s) => s.trim())), ); const t = Number(parts.t); const v1 = parts.v1; if (!Number.isFinite(t) || !v1) return false; if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: keep the raw body for this route const express = require('express'); const app = express(); app.post( '/webhooks/octane', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); const ok = verifyOctaneSignature( rawBody, req.get('X-Octane-Signature'), process.env.OCTANE_WEBHOOK_SECRET, ); if (!ok) return res.status(401).send('invalid signature'); const event = JSON.parse(rawBody); // Acknowledge first, process asynchronously res.sendStatus(200); queue.enqueue(event); }, ); ``` ```python import hmac import hashlib import time import os TOLERANCE_SECONDS = 300 def verify_octane_signature(raw_body: bytes, header: str | None, secret: str) -> bool: if not header: return False try: parts = dict(kv.strip().split("=", 1) for kv in header.split(",")) t = int(parts["t"]) v1 = parts["v1"] except (ValueError, KeyError): return False if abs(time.time() - t) > TOLERANCE_SECONDS: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) # Flask from flask import Flask, request, abort app = Flask(__name__) @app.post("/webhooks/octane") def octane_webhook(): raw = request.get_data() # raw bytes, not request.json if not verify_octane_signature(raw, request.headers.get("X-Octane-Signature"), os.environ["OCTANE_WEBHOOK_SECRET"]): abort(401) event = request.get_json() enqueue(event) # process asynchronously return "", 200 ``` ```php TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']); } // Plain PHP endpoint $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_OCTANE_SIGNATURE'] ?? null; if (!verifyOctaneSignature($rawBody, $header, getenv('OCTANE_WEBHOOK_SECRET'))) { http_response_code(401); exit('invalid signature'); } $event = json_decode($rawBody, true); http_response_code(200); // process $event after responding (e.g. push to a queue) ``` ```csharp using System.Security.Cryptography; using System.Text; public static class OctaneWebhook { private const int ToleranceSeconds = 300; public static bool Verify(string rawBody, string? header, string secret) { if (string.IsNullOrEmpty(header)) return false; var parts = header.Split(',') .Select(kv => kv.Trim().Split('=', 2)) .Where(kv => kv.Length == 2) .ToDictionary(kv => kv[0], kv => kv[1]); if (!parts.TryGetValue("t", out var tStr) || !parts.TryGetValue("v1", out var v1)) return false; if (!long.TryParse(tStr, out var t)) return false; var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(now - t) > ToleranceSeconds) return false; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}")); byte[] provided; try { provided = Convert.FromHexString(v1); } catch (FormatException) { return false; } return CryptographicOperations.FixedTimeEquals(expected, provided); } } // ASP.NET Core minimal API app.MapPost("/webhooks/octane", async (HttpRequest request) => { using var reader = new StreamReader(request.Body, Encoding.UTF8); var rawBody = await reader.ReadToEndAsync(); var header = request.Headers["X-Octane-Signature"].FirstOrDefault(); if (!OctaneWebhook.Verify(rawBody, header, Environment.GetEnvironmentVariable("OCTANE_WEBHOOK_SECRET")!)) return Results.Unauthorized(); // enqueue rawBody for asynchronous processing return Results.Ok(); }); ``` ```java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HashMap; import java.util.HexFormat; import java.util.Map; public final class OctaneWebhook { private static final long TOLERANCE_SECONDS = 300; public static boolean verify(String rawBody, String header, String secret) { if (header == null) return false; Map parts = new HashMap<>(); for (String kv : header.split(",")) { String[] p = kv.trim().split("=", 2); if (p.length == 2) parts.put(p[0], p[1]); } String tStr = parts.get("t"); String v1 = parts.get("v1"); if (tStr == null || v1 == null) return false; long t; try { t = Long.parseLong(tStr); } catch (NumberFormatException e) { return false; } long now = System.currentTimeMillis() / 1000; if (Math.abs(now - t) > TOLERANCE_SECONDS) return false; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] expected = mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8)); byte[] provided = HexFormat.of().parseHex(v1); return MessageDigest.isEqual(expected, provided); } catch (Exception e) { return false; } } } // Spring Boot controller @RestController public class OctaneWebhookController { @PostMapping(value = "/webhooks/octane", consumes = "application/json") public ResponseEntity receive(@RequestBody String rawBody, @RequestHeader("X-Octane-Signature") String signature) { String secret = System.getenv("OCTANE_WEBHOOK_SECRET"); if (!OctaneWebhook.verify(rawBody, signature, secret)) { return ResponseEntity.status(401).build(); } // enqueue rawBody for asynchronous processing return ResponseEntity.ok().build(); } } ``` ## Common mistakes [#common-mistakes] * **Parsing the body before hashing.** Frameworks that parse JSON automatically often discard the raw bytes. Configure the route to keep them, as shown above. * **Hashing the body alone.** The signed payload is `t + "." + body`, not the body by itself. * **Comparing with `==`.** Use the constant-time comparison your platform provides. * **Clock drift.** If legitimate requests fail the timestamp check, sync your server clock with NTP before widening the tolerance. * **Upper-case hex.** Octane sends lower-case hex. Compare bytes, or normalise case before comparing strings. ## How do I test locally? [#how-do-i-test-locally] Ask Octane to send a `webhook.test` event, or generate one yourself with your secret: ```bash SECRET="your_webhook_secret" BODY='{"id":"test","type":"webhook.test","created_at":"2026-09-20T09:00:03Z","api_version":"2026-09-01","data":{}}' T=$(date +%s) SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') curl -X POST https://localhost:8443/webhooks/octane \ -H "Content-Type: application/json" \ -H "X-Octane-Event: webhook.test" \ -H "X-Octane-Delivery: test" \ -H "X-Octane-Signature: t=$T,v1=$SIG" \ --data-binary "$BODY" ``` # المصادقة والوصول (/ar/docs/authentication) أرسل مفتاح API في ترويسة `X-API-Key` مع كل طلب. هذا هو نموذج المصادقة كله: لا تسجيل دخول ولا رموز مؤقتة ولا تجديد. يُقبل الطلب فقط عندما يكون المفتاح نشطًا، وتكامل الواجهة مفعّلًا على حسابك، ولحسابك ملف تسعير نشط، ويأتي الطلب من عنوان IP مسموح به. ## مفتاح API [#مفتاح-api] يحمل كل طلب إلى واجهة التكامل مفتاح API ثابتًا في ترويسة `X-API-Key`: ```http GET /api/v1/integration/transactions?from=...&to=... HTTP/1.1 Host: prod-app.octanetech-api.com X-API-Key: oct_live_a1b2c3d4_Gk7fP2xQ9vLm3nRt8wYb5cHj6sDz1eAu4iFo0pNq ``` | الخاصية | التفصيل | | ------------------ | ---------------------------------------------------------------------------------------------------------------- | | الصيغة | `oct_live__` في الإنتاج، و`oct_test__` في بيئة الاختبار | | مدة الصلاحية | لا تنتهي صلاحية المفتاح من تلقاء نفسه. يتوقف عن العمل فقط عندما تعطّله أوكتين أو يفقد حسابك الوصول (انظر أدناه). | | التدوير | قيمة المفتاح لا تتغير أبدًا. إذا تسرّب المفتاح، تعطّله أوكتين وتصدر مفتاحًا جديدًا ببادئة جديدة. | | التخزين لدى أوكتين | يُخزَّن تجزئة SHA-256 للمفتاح فقط. لا تستطيع أوكتين استعادة مفتاح مفقود؛ يُصدر مفتاح جديد بدلًا منه. | | النطاق | حساب عميل واحد، بما فيه **كل** مجموعات الشركات والشركات التابعة له. | لا تضمّن المفتاح أبدًا في تطبيق جوال أو كود متصفح أو مستودع عام. أرسله عبر HTTPS فقط، ومن عناوين IP الموجودة في قائمتك المسموح بها فقط. ### بادئة المفتاح [#بادئة-المفتاح] المقطع `` (مثل `a1b2c3d4`) ليس سريًا. تستخدمه أوكتين للإشارة إلى مفتاحك في السجلات ومحادثات الدعم. اذكره عند التواصل مع أوكتين بشأن مفتاح؛ ولا ترسل المفتاح الكامل أبدًا. ## ما يمكن للمفتاح الوصول إليه [#ما-يمكن-للمفتاح-الوصول-إليه] يُستنتج حساب العميل دائمًا من المفتاح، **وليس** من معاملات الطلب أبدًا. لا يستطيع المفتاح قراءة سوى معاملات عميله. إذا صفّيت بـ `corporate_id` أو `corporate_group_id`، فيجب أن تنتمي القيم إلى حسابك؛ وأي قيمة أخرى تعيد `404`. إذا كان لمؤسستك عدة حسابات عملاء لدى أوكتين، فيحتاج كل حساب إلى مفتاحه الخاص. ## متى يُقبل الطلب؟ [#متى-يُقبل-الطلب] ينجح الطلب فقط عندما تتحقق كل الشروط التالية، بهذا الترتيب: 1. **يأتي الطلب من عنوان IP مسموح به.** وإلا `403 IP_NOT_ALLOWED`. راجع [الشبكة وحدود المعدل](/ar/docs/network-and-rate-limits). 2. **المفتاح ضمن حد المعدل.** وإلا `429 RATE_LIMITED`. 3. **المفتاح صالح ونشط.** وإلا `401 INVALID_API_KEY`. يُعاد الرمز نفسه سواء كان المفتاح غير معروف أو معطّلًا. 4. **تكامل الواجهة مفعّل على حسابك.** وإلا `403 API_INTEGRATION_NOT_ENABLED`. 5. **لحسابك ملف تسعير نشط وغير منتهٍ.** وإلا `403 NO_ACTIVE_CHARGING_PROFILE`. يحدد الشرطان 4 و5 أيضًا ما إذا كانت الويب هوك تُرسل: إذا فشل أحدهما تتوقف التسليمات حتى يعود الوصول. ### انتهاء الوصول [#انتهاء-الوصول] عندما يكون لملف التسعير تاريخ انتهاء، تتضمن الاستجابات الناجحة ترويسة `X-Access-Expires-At` بذلك التاريخ بصيغة ISO 8601. ينتهي الوصول في تلك اللحظة بالضبط ما لم يُجدَّد الملف. راقب هذه الترويسة وجدّد مبكرًا لتجنب الانقطاع. ```http HTTP/1.1 200 OK Content-Type: application/json X-Access-Expires-At: 2026-12-31T21:59:59Z ``` ## ماذا تعني رموز 401 و403؟ [#ماذا-تعني-رموز-401-و403] | الحالة | الرمز | ما ينبغي فعله | | ------ | ----------------------------- | ------------------------------------------------------------------------------- | | `401` | `INVALID_API_KEY` | تحقق من اسم الترويسة وقيمتها. إذا عُطّل المفتاح، اطلب من أوكتين مفتاحًا جديدًا. | | `403` | `API_INTEGRATION_NOT_ENABLED` | اطلب من مدير حسابك في أوكتين تفعيل تكامل الواجهة على حسابك. | | `403` | `NO_ACTIVE_CHARGING_PROFILE` | ليس لحسابك ملف تسعير نشط، أو انتهى. تواصل مع أوكتين. | | `403` | `IP_NOT_ALLOWED` | جاء الطلب من عنوان IP غير موجود في قائمتك. أرسل العنوان الجديد إلى أوكتين. | | `403` | `EDGE_NOT_ENFORCED` | مشكلة إعداد من جانب أوكتين. تواصل مع دعم أوكتين مع بادئة مفتاحك و`request_id`. | تشترك كل الأخطاء في [الشكل نفسه](/ar/docs/pull-api/errors). ## المفاتيح والبيئات [#المفاتيح-والبيئات] تعمل مفاتيح الإنتاج (`oct_live_`) فقط على مضيف الإنتاج، ومفاتيح الاختبار (`oct_test_`) فقط على بيئة الاختبار. راجع [البيئات](/ar/docs/environments). # أفضل الممارسات (/ar/docs/best-practices) يستخدم التكامل الأكثر موثوقية الويب هوك للسرعة ومزامنة مجدولة عبر واجهة السحب للاكتمال، مع اعتماد كل شيء على معرّف المعاملة `id`. تغطي الأقسام التالية التفاصيل التي تُربك كثيرين. ## اجمع بين الويب هوك وواجهة السحب [#اجمع-بين-الويب-هوك-وواجهة-السحب] استخدم الويب هوك للتحديثات شبه الفورية وواجهة السحب للتسوية. لا تغطي أي قناة وحدها كل الحالات: | الحاجة | القناة | | ------------------------------------------------- | ----------- | | معرفة معاملة جديدة خلال ثوانٍ | الويب هوك | | جلب السجل، أو الفترة السابقة لضبط عنوان الويب هوك | واجهة السحب | | التعافي بعد تعطل نقطة النهاية أو تعطيل الويب هوك | واجهة السحب | | اكتشاف المعاملات الملغاة بعد التسليم | واجهة السحب | | تسوية كشف الحساب الشهري | واجهة السحب | تصميم شائع: عالج الويب هوك فور وصوله، وشغّل مزامنة سحب مجدولة (مثلًا كل 15 دقيقة لآخر ساعتين، وليليًا لآخر 7 أيام) تنفذ upsert بمعرّف المعاملة `id`. ## كيف أكتشف المعاملات الملغاة؟ [#كيف-أكتشف-المعاملات-الملغاة] لا تبلّغ واجهة السحب ولا الويب هوك عن معاملات `VOID` أو `CANCELED`. المعاملة التي تُلغى بعد استلامها **تختفي** من نتائج واجهة السحب لنطاق تاريخها. لاكتشاف ذلك، أعد الاستعلام دوريًا عن النطاقات الحديثة وقارن بما لديك: 1. اجلب كل المعاملات لآخر N يوم (7 قيمة افتراضية معقولة؛ وسّعها إذا استغرقت نزاعاتك وقتًا أطول). 2. أي معاملة لديك بتاريخ `created_at` ضمن ذلك النطاق **وغائبة** عن الاستجابة قد أُلغيت. علّمها وفق ذلك. 3. تصل الاستردادات للتكرار والفروق والنزاعات كمعاملات `CONFIRMED` جديدة مع `correction_reference_id` مضبوط. اربطها بالأصل. ## نفّذ upsert بمعرّف المعاملة [#نفّذ-upsert-بمعرّف-المعاملة] عامل `id` كمفتاح أساسي للمعاملة لديك. تعيد القناتان السجل نفسه للمعرّف نفسه، فيتيح لك upsert استقبال حدث أولًا ونتيجة السحب لاحقًا (أو العكس) دون تعارض. ## خزّن المبالغ بدقة [#خزّن-المبالغ-بدقة] * خزّن `fees.total_amount` بست منازل عشرية. يطابق خصم رصيدك تمامًا وهو الرقم الذي تسوّي به كشوف أوكتين. * خزّن المبالغ الأخرى بمنزلتين عشريتين على الأقل. استخدم نوعًا عشريًا لا نوع فاصلة عائمة. ## تعامل مع الصور فورًا [#تعامل-مع-الصور-فورًا] تنتهي روابط الصور بعد ساعة (السحب) أو 24 ساعة (الويب هوك). إن احتجت إلى الصور، نزّلها عند استلام السجل وخزّنها بنفسك. لا تحفظ الرابط أبدًا. ## تسامح مع الحقول الجديدة [#تسامح-مع-الحقول-الجديدة] قد تُضاف حقول جديدة إلى كائن المعاملة ومغلف الويب هوك في أي وقت. تجاهل الحقول التي لا تعرفها ولا تفشل بسببها. ## احترم حد المعدل [#احترم-حد-المعدل] * رقّم الصفحات بـ `limit=100`. * تراجع عند `429` باستخدام `Retry-After`. * شغّل عامل مزامنة واحدًا لكل مفتاح. ## أمّن بيانات اعتمادك [#أمّن-بيانات-اعتمادك] * احتفظ بمفتاح API وسر الويب هوك في مدير أسرار. لا تُدرجهما في المستودع أبدًا. * أرسل الطلبات فقط من عناوين IP في قائمتك؛ وأخبر أوكتين قبل تغيير عناوينك الصادرة. * تحقق من توقيع كل ويب هوك وارفض الطوابع الزمنية القديمة. * إذا اشتبهت في تسرب مفتاح أو سر، تواصل مع أوكتين فورًا. سيُعطَّل المفتاح ويُصدر مفتاح جديد. ## خطط لانتهاء الوصول [#خطط-لانتهاء-الوصول] راقب ترويسة الاستجابة `X-Access-Expires-At`. جدّد ملف التسعير لدى أوكتين قبل ذلك التاريخ لتجنب `403 NO_ACTIVE_CHARGING_PROFILE` وتوقف تسليمات الويب هوك. ## الدعم [#الدعم] عند التواصل مع دعم أوكتين، أرفق: * **بادئة مفتاحك** (لا المفتاح الكامل أبدًا) * `request_id` من استجابة الخطأ، إن وُجد * معرّف التسليم `id` لمشكلات الويب هوك * الطوابع الزمنية بالتوقيت العالمي # سجل التغييرات (/ar/docs/changelog) يُعلن عن التغييرات غير المتوافقة هنا قبل إطلاقها. أما التغييرات الإضافية (حقول جديدة، أنواع أحداث جديدة) فقد تُطلق دون إشعار؛ ابنِ المحلّل لديك ليتجاهل الحقول غير المعروفة. ## إصدار الواجهة `2026-09-01` [#إصدار-الواجهة-2026-09-01] الإصدار العام الأول. * **واجهة السحب:** `GET /api/v1/integration/transactions` مع مرشحات نطاق التاريخ ومجموعة الشركات والشركة والحالة، وترقيم صفحات بالمؤشر، وحتى 100 سجل في الصفحة، ونطاق أقصى 31 يومًا. * **الويب هوك:** حدثا `transaction.confirmed` و`transaction.external`، وتوقيعات HMAC-SHA256، و3 محاولات تسليم، وتعطيل تلقائي بعد فشل مطوّل. * **الوصول:** مفاتيح API ثابتة في `X-API-Key`، وقائمة IP مسموح بها لكل مفتاح، و60 طلبًا في الدقيقة لكل مفتاح. * **البيانات:** معاملات `CONFIRMED` و`EXTERNAL` فقط. تُعاد الرسوم كما هي مسجلة للحالتين. # البيئات (/ar/docs/environments) يوجد عنوان أساسي واحد للإنتاج، `https://prod-app.octanetech-api.com/api/v1/integration`. أما مضيف بيئة الاختبار ومفتاح الاختبار فيقدمهما مدير حسابك في أوكتين أثناء الإعداد. يعمل المفتاح فقط في البيئة التي أُصدر لها. ## العناوين الأساسية [#العناوين-الأساسية] | البيئة | العنوان الأساسي | بادئة المفتاح | | -------- | -------------------------------------------------------- | ------------- | | الإنتاج | `https://prod-app.octanetech-api.com/api/v1/integration` | `oct_live_` | | الاختبار | يقدمه مدير حسابك في أوكتين أثناء الإعداد | `oct_test_` | كل مسارات واجهة السحب في هذه الوثائق نسبية للعنوان الأساسي. مثلًا، نقطة نهاية المعاملات هي: ```text GET https://prod-app.octanetech-api.com/api/v1/integration/transactions ``` ## الفروق بين البيئتين [#الفروق-بين-البيئتين] | | الإنتاج | الاختبار | | -------------------- | ------------------------------ | ---------------------------------------------- | | قائمة IP المسموح بها | مطلوبة لكل مفتاح | غير مطلوبة؛ تقبل مفاتيح الاختبار أي عنوان مصدر | | حد المعدل | 60 طلبًا في الدقيقة لكل مفتاح | 60 طلبًا في الدقيقة لكل مفتاح | | البيانات | معاملاتك الحقيقية | بيانات اختبار | | الويب هوك | تُسلَّم إلى عنوان الإنتاج لديك | تُسلَّم إلى العنوان المضبوط على مفتاح الاختبار | لا تُصدر مفاتيح الاختبار إلا لبيئة الاختبار وتُرفض في الإنتاج. اطلب من مدير حسابك مفتاح اختبار إذا أردت البناء على بيانات اختبار قبل الإطلاق. ## إصدار الواجهة [#إصدار-الواجهة] تحمل حمولات الويب هوك حقل `api_version` (حاليًا `2026-09-01`). شكل استجابة واجهة السحب وكائن `data.transaction` في الويب هوك بالإصدار نفسه. سيُعلن عن أي تغييرات غير متوافقة في [سجل التغييرات](/ar/docs/changelog) قبل إطلاقها. قد تُضاف حقول جديدة إلى الاستجابات والحمولات في أي وقت دون تغيير الإصدار. ابنِ المحلّل لديك ليتجاهل الحقول غير المعروفة. ## النقل [#النقل] * HTTPS فقط. لا يُقدَّم HTTP العادي. * الاستجابات بصيغة JSON بترميز UTF-8 مع `Content-Type: application/json`. * كل الطوابع الزمنية بصيغة ISO 8601 بالتوقيت العالمي (اللاحقة `Z`). # مقدمة (/ar/docs) تمنح **واجهة تكامل أوكتين** أنظمتك وصولًا موثوقًا وشبه فوري إلى بيانات معاملات الوقود الخاصة بك. صُممت لتكاملات تخطيط الموارد وإدارة الأساطيل والمحاسبة، وهي متاحة لعملاء أوكتين عند الطلب. ## طريقتان للحصول على بياناتك [#طريقتان-للحصول-على-بياناتك] تعيد الطريقتان **سجل المعاملة نفسه**، لذا يمكنك البدء بواجهة السحب وإضافة الويب هوك لاحقًا دون تغيير نموذج بياناتك. ## كيف يعمل الوصول [#كيف-يعمل-الوصول] | | | | ------------ | ------------------------------------------------------------------------------------------- | | **المصادقة** | مفتاح API ثابت طويل الأمد يُرسل في ترويسة `X-API-Key`. لا تسجيل دخول ولا تجديد رموز. | | **النطاق** | ينتمي المفتاح إلى حساب عميل واحد ويغطي كل مجموعات الشركات والشركات التابعة له. | | **الشبكة** | مفاتيح الإنتاج مقيّدة بقائمة عناوين IP المسموح بها، وكل مفتاح محدود بـ 60 طلبًا في الدقيقة. | | **البيانات** | لا تُعاد أو تُرسل سوى المعاملات بحالة `CONFIRMED` و`EXTERNAL`. | تُصدر أوكتين المفاتيح وتديرها. للبدء، اطلب من مدير حسابك في أوكتين تفعيل التكامل على حسابك. راجع [دليل البداية السريعة](/ar/docs/quickstart) لقائمة الإعداد الكاملة. ## ما لا تشمله الواجهة [#ما-لا-تشمله-الواجهة] * إدارة المفاتيح ذاتيًا: تُنشأ المفاتيح وتُفعَّل وتُعطَّل من جانب أوكتين. * إشعارات بتغيّرات الحالة اللاحقة (مثل إلغاء معاملة بعد تأكيدها). راجع [اكتشاف المعاملات الملغاة](/ar/docs/best-practices). * مفاتيح مقيّدة بمجموعة شركات أو شركة واحدة. المفتاح يغطي حساب العميل بالكامل دائمًا. ## إلى أين بعد ذلك [#إلى-أين-بعد-ذلك] # الشبكة وحدود المعدل (/ar/docs/network-and-rate-limits) تنطبق قاعدتان شبكيتان على كل مفتاح إنتاج: يجب أن تأتي الطلبات من عناوين IP التي سجلتها لدى أوكتين، ولا يجوز للمفتاح أن يرسل أكثر من 60 طلبًا في الدقيقة. تُفرض القاعدتان قبل وصول الطلب إلى الواجهة، فيحصل الطلب المحظور على خطأ JSON قصير لا غير. ## قائمة IP المسموح بها [#قائمة-ip-المسموح-بها] كل مفتاح إنتاج مقيّد بقائمة عناوين IP أو نطاقات CIDR تقدمها أنت. تُرفض الطلبات من أي عنوان مصدر آخر: ```json HTTP/1.1 403 Forbidden { "error": { "code": "IP_NOT_ALLOWED" } } ``` * قدّم عناوين IP **العامة الصادرة** للأنظمة التي تستدعي الواجهة. إذا كنت خلف بوابة NAT أو مزود سحابي، فهذا عنوان البوابة لا العنوان الداخلي. * تُقبل العناوين الفردية ونطاقات CIDR. * تمر تغييرات القائمة عبر أوكتين. أرسل العناوين الجديدة إلى مدير حسابك قبل أي تغيير في بنيتك التحتية؛ يُطبَّق التحديث من جانب أوكتين وليس فوريًا. * تنطبق القائمة على واجهة السحب فقط. الويب هوك صادر من أوكتين ولا يتأثر. * مفاتيح الاختبار غير مقيّدة بعنوان IP. تُنتَج استجابتا `IP_NOT_ALLOWED` و`RATE_LIMITED` على الحافة ولا تحتويان على `request_id` أو `message`. اذكر بادئة مفتاحك والطابع الزمني عند التواصل مع الدعم بشأنهما. ## حد المعدل [#حد-المعدل] يجوز لكل مفتاح إرسال **60 طلبًا لكل نافذة 60 ثانية**. يُرفض الطلب رقم 61 في النافذة حتى تُعاد النافذة: ```http HTTP/1.1 429 Too Many Requests Retry-After: 60 Content-Type: application/json { "error": { "code": "RATE_LIMITED" } } ``` * الحد لكل مفتاح. لا تعتمد على توزيع الطلبات على عدة عناوين IP للحصول على سعة أكبر. * لا توجد حصة يومية منفصلة. * يمكن لأوكتين ضبط حد مختلف لمفتاح عند الطلب إذا كان لتكاملك حاجة مبررة. ### ماذا أفعل عند 429؟ [#ماذا-أفعل-عند-429] احترم ترويسة `Retry-After` (بالثواني) وانتظر على الأقل تلك المدة قبل إعادة المحاولة. نمط بسيط: ```js async function octaneFetch(url, options, attempt = 0) { const res = await fetch(url, options); if (res.status === 429 && attempt < 5) { const retryAfter = Number(res.headers.get('Retry-After') ?? 60); await new Promise((r) => setTimeout(r, retryAfter * 1000)); return octaneFetch(url, options, attempt + 1); } return res; } ``` ### كيف أبقى تحت الحد؟ [#كيف-أبقى-تحت-الحد] * استخدم `limit=100` عند جلب السجل لتقليل عدد الطلبات. * زامن وفق جدول بدلًا من الاستطلاع المستمر. مزامنة كل 15 دقيقة لآخر ساعة نمط شائع. * فضّل الويب هوك للاحتياجات شبه الفورية واستخدم واجهة السحب للتسوية. * شغّل عملية مزامنة واحدة لكل مفتاح؛ العمال المتوازون الذين يتشاركون مفتاحًا يستنفدون الحد بسرعة. # الأخطاء (/ar/docs/pull-api/errors) كل خطأ هو كائن JSON واحد يحوي `code` ثابتًا تبني عليه المنطق، و`message` مقروءة، و`request_id` تذكره للدعم. أصلح أخطاء `4xx` وأعد الإرسال؛ ولا تعد المحاولة إلا مع `429` بعد `Retry-After` ومع `5xx` بتراجع تدريجي. ## شكل الخطأ [#شكل-الخطأ] كل استجابة خطأ هي JSON بكائن `error` واحد: ```json { "error": { "code": "VALIDATION_FAILED", "message": "Validation failed", "request_id": "8f3c2a1e-4b6d-4e7f-9a0b-1c2d3e4f5a6b", "fields": { "to": "range exceeds 31 days" } } } ``` | الحقل | الوصف | | ------------ | ------------------------------------------------------------- | | `code` | رمز ثابت مقروء آليًا. ابنِ المنطق عليه لا على `message`. | | `message` | ملخص مقروء للبشر. قد يتغير؛ لا تحلّله. | | `request_id` | معرّف الطلب. أرفقه عند التواصل مع دعم أوكتين. | | `fields` | فقط مع `VALIDATION_FAILED`: خريطة من اسم المعامل إلى المشكلة. | الاستجابات المنتَجة على الحافة (`IP_NOT_ALLOWED`، `RATE_LIMITED`) تحوي `code` فقط. ## رموز الأخطاء [#رموز-الأخطاء] | الحالة | الرمز | المعنى | ما ينبغي فعله | | ------ | ----------------------------- | ----------------------------------------- | -------------------------------------------------------------------------------------------------------- | | `400` | `VALIDATION_FAILED` | معامل مفقود أو غير صالح أو خارج النطاق. | اقرأ `fields` وأصلح الطلب. لا تعد المحاولة دون تغيير. | | `401` | `INVALID_API_KEY` | المفتاح مفقود أو غير معروف أو معطّل. | تحقق من ترويسة `X-API-Key`. إذا عُطّل المفتاح، احصل على مفتاح جديد من أوكتين. لا تعد المحاولة دون تغيير. | | `403` | `IP_NOT_ALLOWED` | عنوان IP المصدر ليس في قائمة المفتاح. | أرسل العنوان إلى أوكتين لإضافته. لا تعد المحاولة حتى يُضاف. | | `403` | `API_INTEGRATION_NOT_ENABLED` | تكامل الواجهة معطّل على حسابك. | تواصل مع مدير حسابك في أوكتين. | | `403` | `NO_ACTIVE_CHARGING_PROFILE` | ليس لحسابك ملف تسعير نشط وغير منتهٍ. | تواصل مع مدير حسابك في أوكتين للتجديد. | | `403` | `EDGE_NOT_ENFORCED` | مشكلة إعداد من جانب أوكتين. | تواصل مع دعم أوكتين مع `request_id`. | | `404` | `CORPORATE_NOT_FOUND` | قيمة `corporate_id` ليست ضمن حسابك. | تحقق من المعرّف. | | `404` | `CORPORATE_GROUP_NOT_FOUND` | قيمة `corporate_group_id` ليست ضمن حسابك. | تحقق من المعرّف. | | `429` | `RATE_LIMITED` | أكثر من 60 طلبًا في الدقيقة الحالية. | انتظر `Retry-After` ثانية وأعد المحاولة. راجع [حد المعدل](/ar/docs/network-and-rate-limits). | | `5xx` | | خطأ مؤقت في الخادم. | أعد المحاولة بتراجع أسّي. | ## أي الأخطاء أعيد محاولتها؟ [#أي-الأخطاء-أعيد-محاولتها] | الحالة | إعادة المحاولة؟ | | -------------------------- | ------------------------------------------------------------ | | `400`، `401`، `403`، `404` | لا. أصلح السبب أولًا. | | `429` | نعم، بعد `Retry-After` ثانية. | | `5xx`، انتهاء مهلة الشبكة | نعم، بتراجع أسّي وحد أقصى (مثلًا 5 محاولات تبدأ من ثانيتين). | عادةً ما تشير أخطاء التحقق في نقطة نهاية المعاملات إلى إحدى هذه المشكلات في `fields`: | المعامل | المشكلات الشائعة | | -------------- | --------------------------------------------------------------------- | | `from`، `to` | مفقود، أو ليس ISO 8601، أو `to` قبل `from`، أو النطاق يتجاوز 31 يومًا | | `limit` | ليس عددًا صحيحًا، أو أقل من 1، أو أكبر من 100 | | `status` | قيمة غير `CONFIRMED` أو `EXTERNAL` | | `corporate_id` | ليس عددًا صحيحًا أو قائمة أعداد صحيحة مفصولة بفواصل | | `cursor` | غير صالح | # ترقيم الصفحات (/ar/docs/pull-api/pagination) للتنقل بين النتائج، كرر الطلب مع `cursor=` والمرشحات نفسها حتى تصبح `has_more` هي `false`. المؤشر معتم ومبني على مجموعة المفاتيح، لذا لا تسبب المعاملات الجديدة الواصلة أثناء المزامنة أي تخطٍّ أو تكرار. تستخدم نقطة نهاية المعاملات **ترقيم صفحات بالمؤشر (keyset)**. تتضمن كل استجابة كائن `pagination`: ```json { "pagination": { "limit": 100, "has_more": true, "next_cursor": "eyJ0IjoiMjAyNi0wOS0yMFQwODo1OTozNi4zNDBaIiwiaWQiOjE4NTQzMn0" } } ``` | الحقل | الوصف | | ------------- | ----------------------------------------------------------------- | | `limit` | حجم الصفحة المطبق. | | `has_more` | `true` إذا وُجدت صفحة أخرى. | | `next_cursor` | رمز معتم للصفحة التالية. `null` عندما تكون `has_more` هي `false`. | ## كيف أتنقل بين الصفحات؟ [#كيف-أتنقل-بين-الصفحات] 1. نفّذ الطلب الأول بمرشحاتك دون `cursor`. 2. طالما `has_more` هي `true`، كرر الطلب **بالمرشحات نفسها** و`cursor=`. 3. توقف عندما تصبح `has_more` هي `false`. تُرتَّب النتائج الأحدث أولًا حسب `created_at` ثم `id`. ولأن المؤشر يشفّر موضع آخر سجل لا إزاحة رقمية، فإن المعاملات الواصلة أثناء التنقل لا تسبب تخطي سجلات أو تكرارها. ## القواعد [#القواعد] * تعامل مع المؤشر كقيمة معتمة. قد تتغير صيغته؛ لا تحلّله ولا تبنِه. * مرر دائمًا قيم `from` و`to` و`corporate_id` و`corporate_group_id` و`status` نفسها مع كل صفحة. المؤشر لا معنى له إلا للاستعلام الذي أنتجه. * استخدم `limit=100` للمزامنات الكبيرة لتقليل عدد الطلبات مقابل [حد المعدل](/ar/docs/network-and-rate-limits). ## مثال: جلب نطاق كامل [#مثال-جلب-نطاق-كامل] ```js async function* transactions(apiKey, filters) { const base = 'https://prod-app.octanetech-api.com/api/v1/integration/transactions'; let cursor; do { const url = new URL(base); for (const [k, v] of Object.entries(filters)) url.searchParams.set(k, String(v)); url.searchParams.set('limit', '100'); if (cursor) url.searchParams.set('cursor', cursor); const res = await fetch(url, { headers: { 'X-API-Key': apiKey } }); if (!res.ok) throw new Error(`Octane API ${res.status}: ${await res.text()}`); const { data, pagination } = await res.json(); yield* data; cursor = pagination.has_more ? pagination.next_cursor : undefined; } while (cursor); } for await (const tx of transactions(process.env.OCTANE_API_KEY, { from: '2026-09-01T00:00:00Z', to: '2026-09-30T23:59:59Z', })) { console.log(tx.id, tx.status, tx.fees.total_amount); } ``` ```python import os, requests BASE = "https://prod-app.octanetech-api.com/api/v1/integration/transactions" def transactions(api_key, **filters): cursor = None while True: params = {**filters, "limit": 100} if cursor: params["cursor"] = cursor res = requests.get(BASE, headers={"X-API-Key": api_key}, params=params, timeout=30) res.raise_for_status() body = res.json() yield from body["data"] if not body["pagination"]["has_more"]: break cursor = body["pagination"]["next_cursor"] for tx in transactions(os.environ["OCTANE_API_KEY"], **{"from": "2026-09-01T00:00:00Z", "to": "2026-09-30T23:59:59Z"}): print(tx["id"], tx["status"], tx["fees"]["total_amount"]) ``` ## كيف أجلب أكثر من 31 يومًا؟ [#كيف-أجلب-أكثر-من-31-يومًا] يغطي الطلب الواحد 31 يومًا كحد أقصى. لمزامنة فترة أطول، قسّمها إلى نوافذ متتالية وتنقّل عبر كل واحدة: ```js function* windows(from, to, days = 31) { let start = new Date(from); const end = new Date(to); while (start < end) { const next = new Date(Math.min(start.getTime() + days * 86_400_000, end.getTime())); yield [start.toISOString(), next.toISOString()]; start = next; } } ``` # قائمة المعاملات (/ar/docs/pull-api/transactions) ```http GET /api/v1/integration/transactions ``` يعيد معاملات الوقود بحالة `CONFIRMED` و`EXTERNAL` الخاصة بالمستدعي والمنشأة ضمن نطاق تاريخ، الأحدث أولًا، مع ترقيم صفحات بالمؤشر. يُستنتج حساب العميل من مفتاح API. ## معاملات الاستعلام [#معاملات-الاستعلام] | المعامل | النوع | مطلوب | الوصف | | -------------------- | -------------------------------- | ----- | -------------------------------------------------------------------------------------------------------- | | `from` | تاريخ ووقت ISO 8601 | نعم | بداية النطاق على `created_at` (شاملة). | | `to` | تاريخ ووقت ISO 8601 | نعم | نهاية النطاق على `created_at`. يمتد النطاق حتى **31 يومًا** كحد أقصى. | | `corporate_group_id` | عدد صحيح | لا | حصر النتائج في مجموعة شركات واحدة. يجب أن تنتمي إلى حسابك. | | `corporate_id` | عدد صحيح أو قائمة مفصولة بفواصل | لا | حصر النتائج في شركة أو أكثر. يجب أن تنتمي إلى حسابك، وإلى المجموعة إذا أُعطي `corporate_group_id` أيضًا. | | `status` | قائمة من `CONFIRMED`، `EXTERNAL` | لا | تصفية حسب الحالة. تُعاد الحالتان عند الحذف. | | `limit` | عدد صحيح | لا | حجم الصفحة. الافتراضي `50`، والأقصى `100`. | | `cursor` | نص | لا | مؤشر معتم من `pagination.next_cursor` في الصفحة السابقة. كرر المرشحات نفسها عند تمريره. | مرر عدة حالات كقائمة مفصولة بفواصل، مثل `status=CONFIRMED,EXTERNAL`. ### أي المعاملات تُعاد؟ [#أي-المعاملات-تُعاد] * معاملات `CONFIRMED` و`EXTERNAL` فقط. لا تُعاد معاملات `PENDING` و`VOID` و`CANCELED` **أبدًا**. المعاملة التي تُلغى بعد جلبها تختفي ببساطة من النتائج اللاحقة للنطاق نفسه. راجع [اكتشاف المعاملات الملغاة](/ar/docs/best-practices). * معاملات الاسترداد (للتكرار أو الفروق أو النزاعات المحلولة) **تُعاد**، مع `correction_reference_id` يشير إلى المعاملة الأصلية. * تسويات الرصيد اليدوية التي يجريها محاسبو أوكتين لا تُعاد أبدًا. ## مثال طلب [#مثال-طلب] ```bash curl -G "https://prod-app.octanetech-api.com/api/v1/integration/transactions" \ -H "X-API-Key: $OCTANE_API_KEY" \ --data-urlencode "from=2026-09-01T00:00:00Z" \ --data-urlencode "to=2026-09-30T23:59:59Z" \ --data-urlencode "corporate_id=67,68" \ --data-urlencode "status=CONFIRMED" \ --data-urlencode "limit=100" ``` ```js const url = new URL('https://prod-app.octanetech-api.com/api/v1/integration/transactions'); url.searchParams.set('from', '2026-09-01T00:00:00Z'); url.searchParams.set('to', '2026-09-30T23:59:59Z'); url.searchParams.set('corporate_id', '67,68'); url.searchParams.set('status', 'CONFIRMED'); url.searchParams.set('limit', '100'); const res = await fetch(url, { headers: { 'X-API-Key': process.env.OCTANE_API_KEY } }); const body = await res.json(); ``` ```python import os, requests res = requests.get( "https://prod-app.octanetech-api.com/api/v1/integration/transactions", headers={"X-API-Key": os.environ["OCTANE_API_KEY"]}, params={ "from": "2026-09-01T00:00:00Z", "to": "2026-09-30T23:59:59Z", "corporate_id": "67,68", "status": "CONFIRMED", "limit": 100, }, timeout=30, ) res.raise_for_status() body = res.json() ``` ```csharp using var http = new HttpClient(); http.DefaultRequestHeaders.Add("X-API-Key", Environment.GetEnvironmentVariable("OCTANE_API_KEY")); var url = "https://prod-app.octanetech-api.com/api/v1/integration/transactions" + "?from=2026-09-01T00:00:00Z&to=2026-09-30T23:59:59Z" + "&corporate_id=67,68&status=CONFIRMED&limit=100"; using var res = await http.GetAsync(url); res.EnsureSuccessStatusCode(); var json = await res.Content.ReadAsStringAsync(); ``` ## مثال استجابة [#مثال-استجابة] ```json { "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.435000 }, "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": 100, "has_more": true, "next_cursor": "eyJ0IjoiMjAyNi0wOS0yMFQwODo1OTozNi4zNDBaIiwiaWQiOjE4NTQzMn0" } } ``` قد تتضمن الاستجابات الناجحة أيضًا ترويسة `X-Access-Expires-At`. راجع [انتهاء الوصول](/ar/docs/authentication). ## كائن المعاملة [#كائن-المعاملة] ### المستوى الأعلى [#المستوى-الأعلى] | الحقل | النوع | الوصف | | ------------------------- | -------------------------- | ------------------------------------------------------------------------ | | `id` | عدد صحيح | معرّف المعاملة الفريد. ثابت بين واجهة السحب وتسليمات الويب هوك. | | `status` | `CONFIRMED` أو `EXTERNAL` | | | `created_at` | تاريخ ووقت | وقت إنشاء المعاملة. ينطبق مرشحا `from` و`to` على هذا الحقل. | | `confirmed_at` | تاريخ ووقت، قد يكون فارغًا | وقت تأكيد المعاملة. | | `correction_reference_id` | عدد صحيح، قد يكون فارغًا | لمعاملات الاسترداد، معرّف المعاملة الأصلية المصحَّحة. `null` في غير ذلك. | | `corporate` | كائن | الشركة التي تتبعها المركبة. موجود دائمًا. | | `corporate_group` | كائن، قد يكون فارغًا | مجموعة الشركة. `null` عندما لا تكون الشركة ضمن مجموعة. | | `fuel` | كائن | نوع الوقود والكمية والسعر. | | `odometer_reading` | عدد صحيح، قد يكون فارغًا | قراءة العداد الملتقطة عند المعاملة، إن توفرت. | | `distance_traveled` | عدد صحيح، قد يكون فارغًا | المسافة منذ المعاملة السابقة للمركبة، عندما يمكن حسابها. | | `fuel_consumption` | رقم، قد يكون فارغًا | لترات لكل 100 كم منذ المعاملة السابقة، عندما يمكن حسابها. | | `station` | كائن | مكان صرف الوقود. | | `vehicle` | كائن | المركبة التي تم تزويدها. | | `driver` | كائن | السائق. | | `fees` | كائن | إجمالي الرسوم وإجمالي الضريبة والإجمالي المحصَّل. | | `images` | كائن | روابط موقّعة لصور المضخة والعداد. | ### `corporate` و`corporate_group` [#corporate-وcorporate_group] | الحقل | النوع | الوصف | | ------ | -------- | ----- | | `id` | عدد صحيح | | | `name` | نص | | ### `fuel` [#fuel] | الحقل | النوع | الوصف | | ----------------- | -------- | ---------------------------------------- | | `type.id` | عدد صحيح | معرّف نوع الوقود. | | `type.name` | نص | اسم نوع الوقود، مثل `Benzine 92`. | | `liters` | رقم | الكمية المصروفة، منزلتان عشريتان. | | `price_per_liter` | رقم | سعر الوحدة، منزلتان عشريتان. | | `amount` | رقم | مبلغ الوقود قبل الرسوم، منزلتان عشريتان. | ### `station` [#station] | الحقل | النوع | الوصف | | ------------- | ------------------------ | -------------------------------------------------------------- | | `id` | عدد صحيح، قد يكون فارغًا | معرّف المحطة. `null` للمحطات الخارجية. | | `name` | نص | اسم المحطة. للمعاملات الخارجية، اسم المحطة الخارجية كما أُدخل. | | `provider` | كائن، قد يكون فارغًا | مزود المحطة (`id`، `name`). `null` للمحطات الخارجية. | | `is_external` | منطقي | `true` عندما تُسجَّل المعاملة في محطة خارج شبكة أوكتين. | ### `vehicle` [#vehicle] | الحقل | النوع | الوصف | | ---------------- | ------------------------ | ------------------------------------------------ | | `id` | عدد صحيح | | | `code` | نص، قد يكون فارغًا | كود المركبة الداخلي لديك كما هو مضبوط في أوكتين. | | `number_plate` | نص، قد يكون فارغًا | | | `chassis_number` | نص، قد يكون فارغًا | | | `brand`، `model` | نص، قد يكون فارغًا | | | `year` | عدد صحيح، قد يكون فارغًا | | | `department` | كائن، قد يكون فارغًا | قسم المركبة (`id`، `name`)، عند تعيينه. | ### `driver` [#driver] | الحقل | النوع | الوصف | | --------------- | -------- | ------------------------------------------------------------------------------------------- | | `id` | عدد صحيح | | | `name` | نص | | | `mobile_number` | نص | موجود **فقط** عندما تفعّل أوكتين أرقام جوال السائقين على مفتاحك. اسأل مدير حسابك إن احتجته. | لا يُعاد البريد الإلكتروني للسائق أبدًا. ### `fees` [#fees] الأرقام الثلاثة نفسها المعروضة في جدول المعاملات بلوحة تحكم أوكتين. | الحقل | النوع | الوصف | | -------------- | ----- | ------------------------------------------------------------------------------------------------------------------------ | | `total_fees` | رقم | كل رسوم الخدمة المطبقة على المعاملة، دون الضريبة. منزلتان عشريتان. | | `total_vat` | رقم | كل ضريبة القيمة المضافة المطبقة على المعاملة. منزلتان عشريتان. | | `total_amount` | رقم | الإجمالي المخصوم من رصيدك: `fuel.amount` + `total_fees` + `total_vat`. **ست منازل عشرية** ليطابق كشف حساب أوكتين تمامًا. | تُعاد الرسوم كما هي مسجلة على المعاملة، لمعاملات `EXTERNAL` كما لمعاملات `CONFIRMED`. تفصيل أنواع الرسوم الفردية ليس جزءًا من الواجهة. ### `images` [#images] | الحقل | النوع | الوصف | | ------------ | ------------------------- | -------------------------------------------- | | `pump` | نص (رابط)، قد يكون فارغًا | صورة شاشة المضخة. `null` عندما لا توجد صورة. | | `odometer` | نص (رابط)، قد يكون فارغًا | صورة العداد. `null` عندما لا توجد صورة. | | `expires_at` | تاريخ ووقت | وقت توقف الروابط عن العمل. | روابط الصور **موقّعة مسبقًا وتنتهي بعد ساعة واحدة**. نزّل الصور فورًا إن احتجت إلى الاحتفاظ بها؛ ولا تخزّن الروابط. أعد جلب المعاملة للحصول على روابط جديدة. ## المعاملات الخارجية [#المعاملات-الخارجية] المعاملة بحالة `EXTERNAL` سُجّلت في محطة خارج شبكة أوكتين. لها شكل المعاملة المؤكدة نفسه، مع هذه الفروق: * `status` هي `EXTERNAL`. * `station.is_external` هي `true`، و`station.id` و`station.provider` هما `null`، و`station.name` هو اسم المحطة الخارجية. * تُعاد `fees` كما هي مسجلة، وقد تكون صفرًا أو غير صفر حسب اتفاقك. ## قواعد الدقة [#قواعد-الدقة] | الحقل | الدقة | | ---------------------------------------------------- | --------------- | | `fuel.liters`، `fuel.price_per_liter`، `fuel.amount` | منزلتان عشريتان | | `fees.total_fees`، `fees.total_vat` | منزلتان عشريتان | | `fees.total_amount` | ست منازل عشرية | | `fuel_consumption` | منزلتان عشريتان | ## الأخطاء [#الأخطاء] | الحالة | الرمز | السبب | | ------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | | `400` | `VALIDATION_FAILED` | معامل مفقود أو غير صالح، أو نطاق يتجاوز 31 يومًا، أو `limit` فوق 100، أو `status` غير معروفة، أو `cursor` غير صالح. يحدد كائن `fields` المعامل المخالف. | | `404` | `CORPORATE_NOT_FOUND` | قيمة `corporate_id` لا تنتمي إلى حسابك (أو إلى المجموعة المعطاة). | | `404` | `CORPORATE_GROUP_NOT_FOUND` | قيمة `corporate_group_id` لا تنتمي إلى حسابك. | بالإضافة إلى أخطاء المصادقة والشبكة المشتركة. راجع [الأخطاء](/ar/docs/pull-api/errors). # البداية السريعة (/ar/docs/quickstart) تحتاج إلى ثلاثة أشياء من أوكتين قبل أول استدعاء: تفعيل تكامل الواجهة على حسابك، وتسجيل عناوين IP الصادرة لديك، ومفتاح API. بعد ذلك يعيد طلب `GET` واحد معاملاتك، وإذا حددت عنوان webhook تصل المعاملات الجديدة من تلقاء نفسها. ### فعّل التكامل [#فعّل-التكامل] اطلب من مدير حسابك في أوكتين تفعيل **تكامل الواجهة** على حسابك. يتطلب الوصول أن يكون التكامل مفعّلًا على الحساب وأن يكون لديك ملف تسعير (charging profile) نشط لدى أوكتين. ### أرسل عناوين IP الخاصة بك [#أرسل-عناوين-ip-الخاصة-بك] لا تقبل مفاتيح الإنتاج الطلبات إلا من قائمة عناوين IP أو نطاقات CIDR تقدمها أنت. أرسل عناوين IP العامة الصادرة للأنظمة التي ستستدعي الواجهة. تُطبَّق القائمة من جانب أوكتين قبل تفعيل مفتاحك، فتُرفض الطلبات من العناوين الأخرى بالرمز `403 IP_NOT_ALLOWED`. اختياريًا، قدّم **عنوان webhook بروتوكول HTTPS** إذا أردت أن ترسل أوكتين المعاملات الجديدة إليك. ### استلم مفتاح API [#استلم-مفتاح-api] تنشئ أوكتين المفتاح وتشاركه معك **مرة واحدة**، ومعه **سر الويب هوك** إذا حددت عنوان webhook. لا تُعرض القيمتان مرة أخرى، فاحفظهما فورًا في مدير أسرار. يبدو المفتاح هكذا: ```text oct_live_a1b2c3d4_Gk7fP2xQ9vLm3nRt8wYb5cHj6sDz1eAu4iFo0pNq ``` البادئة `oct_live_` تعني مفتاح إنتاج. المقطع `a1b2c3d4` هو **بادئة المفتاح** غير السرية التي تستخدمها أوكتين للإشارة إلى مفتاحك في محادثات الدعم. الباقي هو السر. ### نفّذ أول طلب [#نفّذ-أول-طلب] اجلب المعاملات المؤكدة ليوم أمس: ```bash curl "https://prod-app.octanetech-api.com/api/v1/integration/transactions?from=2026-09-20T00:00:00Z&to=2026-09-21T00:00:00Z&limit=50" \ -H "X-API-Key: $OCTANE_API_KEY" ``` ```js const params = new URLSearchParams({ from: '2026-09-20T00:00:00Z', to: '2026-09-21T00:00:00Z', limit: '50', }); const res = await fetch( `https://prod-app.octanetech-api.com/api/v1/integration/transactions?${params}`, { headers: { 'X-API-Key': process.env.OCTANE_API_KEY } }, ); if (!res.ok) throw new Error(`Octane API error ${res.status}: ${await res.text()}`); const { data, pagination } = await res.json(); console.log(data.length, 'transactions, more:', pagination.has_more); ``` ```python import os import requests res = requests.get( "https://prod-app.octanetech-api.com/api/v1/integration/transactions", params={"from": "2026-09-20T00:00:00Z", "to": "2026-09-21T00:00:00Z", "limit": 50}, headers={"X-API-Key": os.environ["OCTANE_API_KEY"]}, timeout=30, ) res.raise_for_status() body = res.json() print(len(body["data"]), "transactions, more:", body["pagination"]["has_more"]) ``` تبدو الاستجابة الناجحة هكذا (مختصرة): ```json { "data": [ { "id": 185432, "status": "CONFIRMED", "created_at": "2026-09-20T08:59:36.340Z", "confirmed_at": "2026-09-20T09:00:02.110Z", "corporate": { "id": 67, "name": "Acme Logistics" }, "fuel": { "type": { "id": 2, "name": "Benzine 92" }, "liters": 18.5, "price_per_liter": 13.75, "amount": 254.38 }, "fees": { "total_amount": 255.435000 } } ], "pagination": { "limit": 50, "has_more": false, "next_cursor": null } } ``` راجع [نقطة نهاية المعاملات](/ar/docs/pull-api/transactions) لكل الحقول. ### استقبل أول webhook (اختياري) [#استقبل-أول-webhook-اختياري] إذا قدّمت عنوان webhook، يمكن لأوكتين إرسال حدث `webhook.test` عند الطلب لتتحقق من الاتصال ومن التحقق من التوقيع قبل وصول الحركة الحقيقية. اطلب من مدير حسابك تشغيله. ينبغي لنقطة النهاية لديك أن: 1. تقرأ جسم الطلب **الخام** (قبل تحليل JSON). 2. تتحقق من ترويسة `X-Octane-Signature` بسر الويب هوك الخاص بك. راجع [التحقق من التوقيع](/ar/docs/webhooks/signature-verification). 3. ترد بأي حالة `2xx` خلال **500 ملّي ثانية**. أكّد الاستلام أولًا ثم عالج الحدث. لا تُرسل سوى المعاملات المنشأة **بعد** ضبط عنوان الويب هوك، فاستخدم واجهة السحب لجلب ما قبل ذلك. ## قائمة الإعداد [#قائمة-الإعداد] * [ ] تكامل الواجهة مفعّل على حسابك في أوكتين * [ ] ملف تسعير نشط * [ ] عناوين IP الصادرة مُرسلة إلى أوكتين * [ ] مفتاح API محفوظ في مدير أسرار * [ ] عنوان الويب هوك (HTTPS على المنفذ 443، متاح علنًا) والسر محفوظان، إن كنت تستخدم الويب هوك * [ ] إعادة المحاولة مع تراجع تدريجي عند `429`، وترقيم الصفحات بالمؤشر مُنفَّذ # التسليم وإعادة المحاولة (/ar/docs/webhooks/delivery-and-retries) تجري أوكتين حتى 3 محاولات لكل تسليم، لكل منها مهلة 500 ملّي ثانية، وتعطّل الويب هوك بعد 50 فشلًا متتاليًا أو 3 أيام من الفشل. معرّف التسليم هو نفسه في كل محاولة، فامنع التكرار به. ## التسليم الواحد [#التسليم-الواحد] لكل معاملة ولكل مفتاح من مفاتيحك له عنوان ويب هوك، تجري أوكتين تسليمًا واحدًا: | القاعدة | القيمة | | ------------- | -------------------------------------------------------------------- | | الطريقة | `POST`، `Content-Type: application/json` | | المهلة | 500 ملّي ثانية من الاتصال حتى اكتمال الاستجابة | | النجاح | أي حالة `2xx`. يُتجاهل الجسم. | | الفشل | أي حالة غير `2xx`، أو انتهاء المهلة، أو خطأ TLS أو اتصال، أو فشل DNS | | إعادة التوجيه | لا تُتبع؛ `3xx` يُحسب فشلًا | المهلة قصيرة عمدًا. يجب ألا ينفذ المعالج لديك أي عمل قبل الرد: لا كتابة في قاعدة بيانات، ولا استدعاءات لواجهات أخرى، ولا تنزيل صور. الأمران الوحيدان اللذان يُنفَّذان قبل الرد هما التحقق من التوقيع وتمرير الحدث الخام إلى طابور أو مهمة خلفية، ثم إعادة `2xx`. البدء البارد ومصافحة TLS وبطء DNS تُحسب كلها من الميزانية. أبقِ نقطة النهاية دافئة، وأنهِ TLS قريبًا من تطبيقك، وتجنب المنصات بلا خادم ذات البدء البارد الذي يستغرق ثوانٍ لهذا المسار. إذا رأيت أخطاء `TIMEOUT` من جانب أوكتين، فهذا أول مكان تنظر فيه. ## كم مرة تعيد أوكتين المحاولة؟ [#كم-مرة-تعيد-أوكتين-المحاولة] يُعاد التسليم الفاشل حتى **3 محاولات إجمالًا**، متباعدة تقريبًا كالتالي: | المحاولة | التأخير التقريبي بعد المحاولة السابقة | | -------- | ------------------------------------- | | 1 | فورًا | | 2 | 30 ثانية | | 3 | 2.5 دقيقة | ترسل كل محاولة الجسم **نفسه** ومعرّف التسليم `id` نفسه؛ ولا يتغير سوى الطابع الزمني للتوقيع وروابط الصور (المعاد توقيعها، صالحة 24 ساعة). بعد فشل المحاولة الثالثة يُسجَّل التسليم فاشلًا من جانب أوكتين مع الاستجابة التي أعادتها نقطة النهاية لديك. يمكن لأوكتين **إعادة تسليم** حدث فاشل عند الطلب. تحمل إعادة التسليم `id` الأصلي. ## متى يُعطَّل الويب هوك؟ [#متى-يُعطَّل-الويب-هوك] إذا استمر فشل التسليمات، يُعطَّل الويب هوك تلقائيًا بعد **50 تسليمًا فاشلًا متتاليًا** أو **3 أيام** من الفشل المستمر، أيهما أسبق. تُخطَر أنت وفريق عمليات أوكتين بالبريد الإلكتروني. * أي تسليم ناجح يعيد ضبط عدّاد الفشل. * أثناء التعطيل لا تُرسل أحداث ولا تُصفّ. استخدم واجهة السحب لملء الفجوة بعد إصلاح نقطة النهاية. * اطلب من أوكتين إعادة تفعيل الويب هوك بعد إصلاح نقطة النهاية. ## عدم التكرار [#عدم-التكرار] تعني إعادة المحاولة وإعادة التسليم أن نقطة النهاية لديك **قد تستقبل الحدث نفسه أكثر من مرة**. معرّف التسليم `id` (الموجود أيضًا في `X-Octane-Delivery`) حتمي لمعاملة ومفتاح معينين، لذا فهو مفتاح آمن لمنع التكرار: ```js async function handle(event) { const inserted = await db.execute( 'INSERT INTO octane_events (delivery_id, received_at) VALUES ($1, now()) ON CONFLICT DO NOTHING', [event.id], ); if (inserted.rowCount === 0) return; // already processed await processTransaction(event.data.transaction); } ``` بدلًا من ذلك، نفّذ upsert على `data.transaction.id`: لا تُرسل المعاملة إلا مرة واحدة لكل مفتاح، فأي تسليم ثانٍ بمعرّف المعاملة نفسه هو دائمًا إعادة محاولة. ## هل تُسلَّم الأحداث بالترتيب؟ [#هل-تُسلَّم-الأحداث-بالترتيب] تُسلَّم الأحداث وفق تأكيد المعاملات، لكن الترتيب بين الأحداث **غير مضمون**، خاصة مع إعادة المحاولات. استخدم `data.transaction.created_at` أو `confirmed_at` إذا كان الترتيب يهمك. ## المراقبة من جانبك [#المراقبة-من-جانبك] * نبّه عند معدل مستمر لفشل التوقيع: يعني غالبًا سرًا خاطئًا أو معالج جسم خام مضبوطًا بشكل خاطئ. * نبّه إذا لم تستقبل أحداثًا لفترة أطول من نمط حركتك المعتاد؛ فقد يكون الويب هوك عُطّل. * سجّل معرّف التسليم `id` مع كل حدث تعالجه ليمكن الإشارة إليه في محادثات الدعم. * سوِّ يوميًا مع [واجهة السحب](/ar/docs/pull-api/transactions) لالتقاط أي شيء فات أثناء تعطل نقطة النهاية. # الأحداث والحمولة (/ar/docs/webhooks/events-and-payload) كل ويب هوك هو طلب `POST` عبر HTTPS بثلاث ترويسات `X-Octane-*` ومغلف JSON يكون فيه `data.transaction` هو الكائن نفسه الذي تعيده واجهة السحب بالضبط. حقل `id` في المغلف هو معرّف التسليم، وهو ثابت عبر إعادة المحاولات. ## الطلب [#الطلب] ```http POST /your/webhook/path HTTP/1.1 Host: erp.example.com Content-Type: application/json User-Agent: Octane-Webhooks/1.0 X-Octane-Event: transaction.confirmed X-Octane-Delivery: 5f0c2e7a-0d3f-5c1a-9b8e-2a4c6d8e0f13 X-Octane-Signature: t=1758358803,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` | الترويسة | الوصف | | -------------------- | ----------------------------------------------------------------------------------------------------------- | | `X-Octane-Event` | نوع الحدث. القيمة نفسها في `type` بالجسم. | | `X-Octane-Delivery` | معرّف التسليم. القيمة نفسها في `id` بالجسم. استخدمه لمنع التكرار. | | `X-Octane-Signature` | الطابع الزمني وتوقيع HMAC-SHA256 للجسم. راجع [التحقق من التوقيع](/ar/docs/webhooks/signature-verification). | | `User-Agent` | دائمًا `Octane-Webhooks/1.0`. | ## المغلف [#المغلف] ```json { "id": "5f0c2e7a-0d3f-5c1a-9b8e-2a4c6d8e0f13", "type": "transaction.confirmed", "created_at": "2026-09-20T09:00:03Z", "api_version": "2026-09-01", "data": { "transaction": { "...": "see below" } } } ``` | الحقل | الوصف | | ------------------ | ----------------------------------------------------------------------------------------------------------------------------------- | | `id` | معرّف التسليم. **حتمي** لمعاملة ومفتاح معينين: تحمل كل إعادة محاولة أو إعادة تسليم للحدث نفسه المعرّف نفسه. خزّنه وتجاهل التكرارات. | | `type` | `transaction.confirmed` أو `transaction.external` أو `webhook.test`. | | `created_at` | وقت إنشاء الحدث، ISO 8601 بالتوقيت العالمي. | | `api_version` | إصدار مخطط الحمولة. حاليًا `2026-09-01`. | | `data.transaction` | كائن المعاملة. مطابق [لكائن المعاملة في واجهة السحب](/ar/docs/pull-api/transactions). | ## أنواع الأحداث [#أنواع-الأحداث] ### `transaction.confirmed` [#transactionconfirmed] يُرسل عندما تصبح المعاملة `CONFIRMED`. قيمة `data.transaction.status` هي `CONFIRMED`. معاملات الاسترداد مشمولة وتحمل `correction_reference_id`. ```json { "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.435000 }, "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" } } } } ``` ### `transaction.external` [#transactionexternal] يُرسل عند تسجيل معاملة `EXTERNAL`. المغلف وشكل المعاملة نفسهما؛ `status` هي `EXTERNAL`، و`station.is_external` هي `true`، و`station.id` و`station.provider` هما `null`، وتُرسل `fees` كما هي مسجلة بالضبط. ### `webhook.test` [#webhooktest] يُرسل عند الطلب من أوكتين لتتحقق من الاتصال ومن التحقق من التوقيع. المغلف نفسه و`type` هي `webhook.test`. تحقق من التوقيع وأعد `2xx`. لا تعامل `data` فيه كمعاملة حقيقية. ## كيف تختلف الحمولة عن واجهة السحب؟ [#كيف-تختلف-الحمولة-عن-واجهة-السحب] | الموضوع | واجهة السحب | الويب هوك | | ---------------------- | -------------------------- | ------------------------------------------------------ | | صلاحية روابط الصور | ساعة واحدة | 24 ساعة، تُعاد التوقيع مع كل محاولة تسليم | | الترتيب | الأحدث أولًا، مرقّم | حدث واحد لكل تسليم، بترتيب تأكيد المعاملات (غير مضمون) | | `mobile_number` للسائق | فقط عند تفعيله على المفتاح | كذلك | كل شيء آخر، بما فيه أسماء الحقول وقابليتها للفراغ والدقة، متطابق. # نظرة عامة على الويب هوك (/ar/docs/webhooks) يتيح الويب هوك لأوكتين إخطار نظامك شبه فوريًا بدلًا من استطلاع واجهة السحب. كلما تم تأكيد إحدى معاملاتك أو تسجيل معاملة خارجية، ترسل أوكتين طلب `POST` عبر HTTPS إلى نقطة النهاية لديك مع سجل المعاملة الكامل. ## كيف يعمل الويب هوك؟ [#كيف-يعمل-الويب-هوك] 1. تعطي أوكتين عنوان HTTPS عند إنشاء مفتاح API (أو لاحقًا عند الطلب). تولّد أوكتين **سر الويب هوك** وتشاركه معك مرة واحدة. 2. عندما تصل معاملة إلى حالة `CONFIRMED` أو `EXTERNAL`، تبني أوكتين الحمولة وترسلها بـ `POST` إلى عنوانك موقّعة بالسر. 3. تتحقق نقطة النهاية لديك من التوقيع وترد بأي حالة `2xx` خلال 500 ملّي ثانية. 4. إذا فشل التسليم، تعيد أوكتين المحاولة حتى 3 مرات. تُسجَّل حالات الفشل المستمر، وبعد فشل مطوّل يُعطَّل الويب هوك وتُخطَر بالبريد الإلكتروني. يُطلق الويب هوك **مرة واحدة** لكل معاملة، عندما تصبح `CONFIRMED` أو `EXTERNAL`. لا تُرسل التغييرات اللاحقة (إلغاء أو إبطال). استخدم واجهة السحب لاكتشافها. راجع [أفضل الممارسات](/ar/docs/best-practices). ## متطلبات نقطة النهاية [#متطلبات-نقطة-النهاية] | المتطلب | التفصيل | | ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ | | البروتوكول والمنفذ | `https://` على المنفذ 443 فقط. يُرفض HTTP العادي والمنافذ غير القياسية. | | إمكانية الوصول | يجب أن يُحلّ اسم المضيف إلى عنوان IP **عام**. تُرفض النطاقات الخاصة والمحلية ونطاقات الربط المحلي وبيانات السحابة الوصفية. | | TLS | شهادة صالحة وموثوقة علنًا. | | إعادة التوجيه | لا تُتبع. يجب أن يرد العنوان مباشرة. | | المهلة | الرد خلال **500 ملّي ثانية**، محسوبة من الاتصال حتى اكتمال الاستجابة. أعد `2xx` فورًا ثم عالج الحدث بشكل غير متزامن. الرد البطيء يُحسب تسليمًا فاشلًا. | | النجاح | أي حالة `2xx`. يُتجاهل جسم الاستجابة. | يُضبط عنوان ويب هوك واحد لكل مفتاح API. إن احتجت إلى عدة وجهات، اطلب من أوكتين مفتاحًا لكل وجهة. ## ما الذي يُطلق الويب هوك؟ [#ما-الذي-يُطلق-الويب-هوك] | الحدث | يُرسل عندما | | ----------------------- | ----------------------------------------------------------------------------------------- | | `transaction.confirmed` | تصبح المعاملة `CONFIRMED`، بما فيها معاملات الاسترداد للتكرار والفروق والنزاعات المحلولة. | | `transaction.external` | تُسجَّل معاملة `EXTERNAL` (محطة خارج شبكة أوكتين). | | `webhook.test` | عند الطلب، للتحقق من نقطة النهاية لديك. | لا يُرسل: معاملات `PENDING` أو `VOID` أو `CANCELED`، أو تغييرات الحالة اللاحقة لمعاملة سُلّمت بالفعل، أو تسويات الرصيد اليدوية. لا تُرسل سوى المعاملات المنشأة **بعد** ضبط عنوان الويب هوك. استخدم واجهة السحب لجلب ما قبل ذلك. ## الخطوات التالية [#الخطوات-التالية] # التحقق من التوقيع (/ar/docs/webhooks/signature-verification) للتحقق من ويب هوك، احسب HMAC-SHA256 على `.` بسر الويب هوك الخاص بك وقارنه، بمقارنة ثابتة الزمن، بقيمة `v1` في ترويسة `X-Octane-Signature`. ارفض أي طلب يفشل، وارفض الطوابع الزمنية الأقدم من بضع دقائق. تحقق دائمًا قبل التصرف بناءً على الحمولة. ## الترويسة [#الترويسة] ```text X-Octane-Signature: t=1758358803,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd ``` | العنصر | الوصف | | ------ | --------------------------------------------------------------------------------- | | `t` | طابع Unix الزمني (بالثواني) الذي وقّعت فيه أوكتين الطلب. | | `v1` | HMAC-SHA256 بسداسي عشري بأحرف صغيرة للنص `.` بسر الويب هوك الخاص بك. | ## خطوات التحقق [#خطوات-التحقق] ### اقرأ الجسم الخام [#اقرأ-الجسم-الخام] استخدم البايتات التي أرسلتها أوكتين بالضبط. لا تعد تسلسل JSON بعد تحليله: أي تغيير في المسافات أو ترتيب المفاتيح يغيّر التوقيع. ### حلّل الترويسة [#حلّل-الترويسة] قسّم على `,` ثم كل جزء على `=` للحصول على `t` و`v1`. ### تحقق من الطابع الزمني [#تحقق-من-الطابع-الزمني] ارفض الطلب إذا كان `t` بعيدًا جدًا عن وقتك الحالي. يُنصح بتسامح **5 دقائق**. هذا يحد من إعادة إرسال الطلبات الملتقطة. ### احسب التوقيع المتوقع [#احسب-التوقيع-المتوقع] `signed_payload = t + "." + raw_body`، ثم `HMAC_SHA256(secret, signed_payload)` بسداسي عشري بأحرف صغيرة. ### قارن بزمن ثابت [#قارن-بزمن-ثابت] قارن قيمتك مع `v1` بدالة مقارنة ثابتة الزمن. المقارنة النصية العادية تسرّب معلومات توقيت. ## أمثلة الكود [#أمثلة-الكود] يعرض كل مثال دالة `verify(rawBody, signatureHeader, secret)` ويوضح كيفية قراءة الجسم الخام في إطار عمل شائع. ```js const crypto = require('node:crypto'); const TOLERANCE_SECONDS = 300; function verifyOctaneSignature(rawBody, header, secret) { if (typeof header !== 'string') return false; const parts = Object.fromEntries( header.split(',').map((kv) => kv.split('=').map((s) => s.trim())), ); const t = Number(parts.t); const v1 = parts.v1; if (!Number.isFinite(t) || !v1) return false; if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false; const expected = crypto .createHmac('sha256', secret) .update(`${t}.${rawBody}`) .digest('hex'); const a = Buffer.from(expected, 'hex'); const b = Buffer.from(v1, 'hex'); return a.length === b.length && crypto.timingSafeEqual(a, b); } // Express: keep the raw body for this route const express = require('express'); const app = express(); app.post( '/webhooks/octane', express.raw({ type: 'application/json' }), (req, res) => { const rawBody = req.body.toString('utf8'); const ok = verifyOctaneSignature( rawBody, req.get('X-Octane-Signature'), process.env.OCTANE_WEBHOOK_SECRET, ); if (!ok) return res.status(401).send('invalid signature'); const event = JSON.parse(rawBody); // Acknowledge first, process asynchronously res.sendStatus(200); queue.enqueue(event); }, ); ``` ```python import hmac import hashlib import time import os TOLERANCE_SECONDS = 300 def verify_octane_signature(raw_body: bytes, header: str | None, secret: str) -> bool: if not header: return False try: parts = dict(kv.strip().split("=", 1) for kv in header.split(",")) t = int(parts["t"]) v1 = parts["v1"] except (ValueError, KeyError): return False if abs(time.time() - t) > TOLERANCE_SECONDS: return False signed = f"{t}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() return hmac.compare_digest(expected, v1) # Flask from flask import Flask, request, abort app = Flask(__name__) @app.post("/webhooks/octane") def octane_webhook(): raw = request.get_data() # raw bytes, not request.json if not verify_octane_signature(raw, request.headers.get("X-Octane-Signature"), os.environ["OCTANE_WEBHOOK_SECRET"]): abort(401) event = request.get_json() enqueue(event) # process asynchronously return "", 200 ``` ```php TOLERANCE_SECONDS) { return false; } $expected = hash_hmac('sha256', $t . '.' . $rawBody, $secret); return hash_equals($expected, $parts['v1']); } // Plain PHP endpoint $rawBody = file_get_contents('php://input'); $header = $_SERVER['HTTP_X_OCTANE_SIGNATURE'] ?? null; if (!verifyOctaneSignature($rawBody, $header, getenv('OCTANE_WEBHOOK_SECRET'))) { http_response_code(401); exit('invalid signature'); } $event = json_decode($rawBody, true); http_response_code(200); // process $event after responding (e.g. push to a queue) ``` ```csharp using System.Security.Cryptography; using System.Text; public static class OctaneWebhook { private const int ToleranceSeconds = 300; public static bool Verify(string rawBody, string? header, string secret) { if (string.IsNullOrEmpty(header)) return false; var parts = header.Split(',') .Select(kv => kv.Trim().Split('=', 2)) .Where(kv => kv.Length == 2) .ToDictionary(kv => kv[0], kv => kv[1]); if (!parts.TryGetValue("t", out var tStr) || !parts.TryGetValue("v1", out var v1)) return false; if (!long.TryParse(tStr, out var t)) return false; var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(now - t) > ToleranceSeconds) return false; using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); var expected = hmac.ComputeHash(Encoding.UTF8.GetBytes($"{t}.{rawBody}")); byte[] provided; try { provided = Convert.FromHexString(v1); } catch (FormatException) { return false; } return CryptographicOperations.FixedTimeEquals(expected, provided); } } // ASP.NET Core minimal API app.MapPost("/webhooks/octane", async (HttpRequest request) => { using var reader = new StreamReader(request.Body, Encoding.UTF8); var rawBody = await reader.ReadToEndAsync(); var header = request.Headers["X-Octane-Signature"].FirstOrDefault(); if (!OctaneWebhook.Verify(rawBody, header, Environment.GetEnvironmentVariable("OCTANE_WEBHOOK_SECRET")!)) return Results.Unauthorized(); // enqueue rawBody for asynchronous processing return Results.Ok(); }); ``` ```java import javax.crypto.Mac; import javax.crypto.spec.SecretKeySpec; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.util.HashMap; import java.util.HexFormat; import java.util.Map; public final class OctaneWebhook { private static final long TOLERANCE_SECONDS = 300; public static boolean verify(String rawBody, String header, String secret) { if (header == null) return false; Map parts = new HashMap<>(); for (String kv : header.split(",")) { String[] p = kv.trim().split("=", 2); if (p.length == 2) parts.put(p[0], p[1]); } String tStr = parts.get("t"); String v1 = parts.get("v1"); if (tStr == null || v1 == null) return false; long t; try { t = Long.parseLong(tStr); } catch (NumberFormatException e) { return false; } long now = System.currentTimeMillis() / 1000; if (Math.abs(now - t) > TOLERANCE_SECONDS) return false; try { Mac mac = Mac.getInstance("HmacSHA256"); mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); byte[] expected = mac.doFinal((t + "." + rawBody).getBytes(StandardCharsets.UTF_8)); byte[] provided = HexFormat.of().parseHex(v1); return MessageDigest.isEqual(expected, provided); } catch (Exception e) { return false; } } } // Spring Boot controller @RestController public class OctaneWebhookController { @PostMapping(value = "/webhooks/octane", consumes = "application/json") public ResponseEntity receive(@RequestBody String rawBody, @RequestHeader("X-Octane-Signature") String signature) { String secret = System.getenv("OCTANE_WEBHOOK_SECRET"); if (!OctaneWebhook.verify(rawBody, signature, secret)) { return ResponseEntity.status(401).build(); } // enqueue rawBody for asynchronous processing return ResponseEntity.ok().build(); } } ``` ## أخطاء شائعة [#أخطاء-شائعة] * **تحليل الجسم قبل التجزئة.** أطر العمل التي تحلل JSON تلقائيًا غالبًا تتخلص من البايتات الخام. اضبط المسار للاحتفاظ بها كما هو موضح أعلاه. * **تجزئة الجسم وحده.** الحمولة الموقّعة هي `t + "." + body`، لا الجسم وحده. * **المقارنة بـ `==`.** استخدم المقارنة ثابتة الزمن التي توفرها منصتك. * **انحراف الساعة.** إذا فشلت الطلبات الصحيحة في فحص الطابع الزمني، زامن ساعة خادمك عبر NTP قبل توسيع التسامح. * **سداسي عشري بأحرف كبيرة.** ترسل أوكتين أحرفًا صغيرة. قارن البايتات، أو وحّد حالة الأحرف قبل مقارنة النصوص. ## كيف أختبر محليًا؟ [#كيف-أختبر-محليًا] اطلب من أوكتين إرسال حدث `webhook.test`، أو ولّد واحدًا بنفسك بسرك: ```bash SECRET="your_webhook_secret" BODY='{"id":"test","type":"webhook.test","created_at":"2026-09-20T09:00:03Z","api_version":"2026-09-01","data":{}}' T=$(date +%s) SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.* //') curl -X POST https://localhost:8443/webhooks/octane \ -H "Content-Type: application/json" \ -H "X-Octane-Event: webhook.test" \ -H "X-Octane-Delivery: test" \ -H "X-Octane-Signature: t=$T,v1=$SIG" \ --data-binary "$BODY" ```