Get started Amigo Developer Platform

Two APIs that share one auth, one wallet, and one rate limit: Data API for mobile-data top-ups across every Nigerian network, and Commerce API New for selling physical products from the Amigo catalog. Built for your backend, not your browser.

Token authOne X-API-Key covers both APIs.
IP whitelistLock tokens to trusted servers in production.
Idempotent retriesSafe retries with Idempotency-Key.
Signed webhooksHMAC-SHA256 events for commerce state changes.

Your first call — buy 1GB of MTN data:

curl --location 'https://amigo.ng/api/data/' \
--header 'X-API-Key: YOUR_API_TOKEN' \
--header 'Content-Type: application/json' \
--data '{
  "network": 1,
  "mobile_number": "09012345678",
  "plan": 1001,
  "Ported_number": true
}'

Overview

Amigo Developers gives you two APIs that share the same auth, token, and wallet:

  • Data API — buy mobile data on MTN, Glo, Airtel, and 9mobile. Per-plan pricing, idempotent retries, real-time efficiency scores.
  • Commerce API New — sell physical products from the Amigo catalog (Amigo + Oraimo accessories). Wallet-paid, fulfilled by Amigo, with signed webhooks for every order event.

Base URL: https://amigo.ng/api. All responses are JSON. Money is integer Naira (no decimals) and most responses also include a pre-formatted display string like "₦4,000".

Authentication

Pass your token in any of these headers — whichever your HTTP client makes easiest:

X-API-Key: amg_live_xxxxxxxxxxxxxxxx
# --or--
Authorization: Token amg_live_xxxxxxxxxxxxxxxx
# --or--
Authorization: Bearer amg_live_xxxxxxxxxxxxxxxx

Each token belongs to a single Amigo user + API client. The user_id identifies whose wallet gets debited; the client_id identifies which merchant owns the resulting work.

Keep tokens out of source control. Generate one per environment (staging vs. production), and rotate immediately if leaked. Whitelisting your IPs is supported — ask support to enable it on your client.

Test mode New

Build and integrate against a sandbox before touching real money. Test-mode tokens are intentionally familiar (Stripe-style) — the token prefix decides the environment:

Token prefixModeWalletOrder referencesInventory
amg_test_… test Sandbox wallet — fake money, starts at ₦1,000,000. AMG-COM-TEST-… Never touched — test orders don't affect live availability.
amg_live_… (or legacy) live Your Amigo wallet (real funds). AMG-COM-… Decremented at order time, restored on cancel.

Total isolation. A test token only sees test orders and test webhook endpoints; a live token only sees live ones. The two namespaces never cross. Every webhook payload includes a livemode boolean so subscribers can route or filter.

Refill the sandbox wallet:

POST /api/commerce/sandbox/reset-balance/ Test mode only

Refills the sandbox wallet back to ₦1,000,000. Requires an amg_test_ token — live tokens get 403 live_token_not_allowed.

curl -X POST "https://amigo.ng/api/commerce/sandbox/reset-balance/" \
  -H "X-API-Key: $TEST_TOKEN"

# → { "success": true, "data": { "test_balance": 1000000, "display": "₦1,000,000", "livemode": false } }
Recommended integration flow. Wire up your storefront with a test token, place a few orders, watch the webhook events fire to your test endpoint, exercise cancel/refund. When everything looks right, swap to a live token — same API surface, same response shapes, real money this time.

Errors

Every non-2xx response uses the same envelope:

{
  "success": false,
  "error":   "insufficient_balance",
  "message": "Wallet balance is below the order total."
}
HTTPerrorWhen
401invalid_tokenMissing, expired, or revoked token.
403ip_not_whitelistedYour client has a whitelist and your IP isn't on it.
404not_foundProduct, order, or endpoint doesn't exist (or isn't yours).
422invalid_*Body validation failed; message explains.
422insufficient_balanceWallet is below the order total.
422out_of_stockItem ran out between catalog read and order create.
429rate_limitedToo many requests in this minute window.
500server_errorUnexpected. Logged on our end; retry with backoff.

Idempotency

Both APIs accept an Idempotency-Key header on state-changing POSTs (buying data, creating orders). Retry with the same key and you get back the original response — no double-debit, no duplicate work.

curl -X POST https://amigo.ng/api/commerce/orders/ \
  -H "X-API-Key: $TOKEN" \
  -H "Idempotency-Key: ord-2026-05-17-7f3a" \
  -H "Content-Type: application/json" \
  --data @order.json

Keys are opaque strings up to 80 chars. Scope is per-token; reuse across tokens has no effect. Cache TTL is 24h.

Data API Mobile data top-ups

One endpoint: POST /api/data/. Pick a plan from the live catalog below, send it with the customer's mobile number, and we route the top-up through the carrier.

POST /api/data/

POST /api/data/ Idempotent

Body:

{
  "network":       1,              // 1=MTN, 2=Glo, 4=Airtel, 3=9mobile
  "mobile_number": "09012345678",
  "plan":          1001,           // from /plans_catalog.php
  "Ported_number": true            // if MNP'd
}
<?php
$ch = curl_init('https://amigo.ng/api/data/');
curl_setopt_array($ch, [
  CURLOPT_RETURNTRANSFER => true,
  CURLOPT_POST => true,
  CURLOPT_HTTPHEADER => [
    'X-API-Key: YOUR_API_TOKEN',
    'Content-Type: application/json',
    'Idempotency-Key: '.bin2hex(random_bytes(16))
  ],
  CURLOPT_POSTFIELDS => json_encode([
    'network'       => 1,
    'mobile_number' => '09012345678',
    'plan'          => 1001,
    'Ported_number' => true
  ])
]);
echo curl_exec($ch);
fetch('https://amigo.ng/api/data/', {
  method: 'POST',
  headers: {
    'X-API-Key': 'YOUR_API_TOKEN',
    'Content-Type': 'application/json',
    'Idempotency-Key': crypto.randomUUID()
  },
  body: JSON.stringify({
    network: 1,
    mobile_number: '09012345678',
    plan: 1001,
    Ported_number: true
  })
}).then(r => r.json()).then(console.log);
curl -X POST https://amigo.ng/api/data/ \
  -H "X-API-Key: $TOKEN" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data '{"network":1,"mobile_number":"09012345678","plan":1001,"Ported_number":true}'

Live plan catalog

These tables refresh on every page load from the same plan APIs your integration can use. Use the Plan ID column when calling POST /data/.

MTN (network 1)
MTN Plans
Plan IDCapacityValidityPrice
Glo (network 2)
Glo Plans
Plan IDCapacityValidityPrice
Airtel (network 4)
Airtel Plans
Plan IDCapacityValidityPrice

Plan efficiency

Reliability scores so you can pick plans that are safer for production traffic. A score ≥ 98% is excellent; < 75% is poor and we recommend avoiding.

MTN
MTN Efficiency
Plan IDCapacityEfficiency
Glo
Glo Efficiency
Plan IDCapacityEfficiency
Airtel
Airtel Efficiency
Plan IDCapacityEfficiency

Commerce API New

Sell from the Amigo catalog through your own app or storefront. Browse products, place orders charged against your Amigo wallet, and get webhook events for every state change — from order.created to order.delivered.

What's in the catalog? 1,297 products across 14 consolidated categories — Amigo and Oraimo accessories (cables, power banks, earbuds, smart wear, etc.). Real prices, real stock counts, refreshed continuously.

How orders are charged. Each order is debited from your Amigo account balance inline at order-creation time. Pre-fund your account so orders just clear — your app never needs to handle card data at checkout.

List products

GET /api/commerce/products/ Catalog

Paginated product listing with filters.

ParamTypeNotes
categorystringoptionalFilter by category slug (e.g. power-banks).
brandstringoptionaloraimo or amigo.
in_stockbooloptionaltrue to return only in-stock items.
qstringoptionalFree-text search across name + description.
sortstringoptionalprice_asc, price_desc, name, new (default).
limitintoptionalDefault 20, max 100.
offsetintoptionalDefault 0.
curl "https://amigo.ng/api/commerce/products/?category=power-banks&in_stock=true&limit=5" \
  -H "X-API-Key: $TOKEN"
{
  "success": true,
  "data": [
    {
      "id": 42,
      "slug": "oraimo-traveler-3-10000mah-power-bank",
      "brand": "oraimo",
      "name": "Oraimo Traveler 3 10000mAh Power Bank",
      "description": "Slim aluminum body with 22.5W fast charge…",
      "price":    { "amount": 14500, "display": "₦14,500", "currency": "NGN", "market": {…} },
      "category": { "id": 5, "slug": "power-banks", "name": "Power Banks" },
      "stock":    { "in_stock": true, "count": 27 },
      "rating":   { "value": 4.6, "count": 312 },
      "images":   [ { "position": 1, "url": "https://res.cloudinary.com/…/traveler-3.jpg" } ],
      "updated_at": "2026-05-16T18:32:11+01:00"
    }
  ],
  "pagination": { "total": 142, "limit": 5, "offset": 0, "has_more": true }
}

Get a single product

GET /api/commerce/products/?id=N or ?slug=X Catalog

Single product. Same shape as a list entry. Use slug for stable URLs in your storefront — slugs don't change when IDs are renumbered.

curl "https://amigo.ng/api/commerce/products/?slug=oraimo-traveler-3-10000mah-power-bank" \
  -H "X-API-Key: $TOKEN"

List categories

GET /api/commerce/categories/ Catalog

All 14 consolidated categories with product counts. Useful for rendering a nav or filter sidebar in your storefront.

{
  "success": true,
  "data": [
    { "id": 1, "slug": "charging-accessories", "name": "Charging Accessories", "product_count": 183 },
    { "id": 2, "slug": "power-banks",          "name": "Power Banks",          "product_count": 142 }
  ]
}

Curation lists

GET /api/commerce/curation/ Auth required

Admin-curated product lists for your storefront — "Featured", "New arrivals", "Bestsellers", "Deals", plus any custom lists Amigo's catalog team adds. Use them to populate your home page, category banners, and discover-style surfaces.

The same admin-curated pool is shared across all developer apps, but the products returned to your token are token-seeded random — so two apps built on Amigo's catalog don't show identical home pages. The order is stable per token: same token + same list = same order every call (cache-friendly, consistent customer UX). Different tokens see different subsets.

Discover available lists

Call with no type param to enumerate what's currently active:

curl 'https://amigo.ng/api/commerce/curation/' \
  -H "X-API-Key: $TOKEN"
{
  "success": true,
  "data": [
    { "slug": "featured",    "name": "Featured",     "source_type": "manual",     "description": "Admin-picked highlights.",       "default_limit": 8, "pool_size": 50, "product_count": 12 },
    { "slug": "new",         "name": "New arrivals", "source_type": "new",        "description": "Recently added products.",        "default_limit": 8, "pool_size": 50, "product_count": 23 },
    { "slug": "bestsellers", "name": "Bestsellers",  "source_type": "bestseller", "description": "Top sellers, last 90 days.",      "default_limit": 8, "pool_size": 50, "product_count":  9 },
    { "slug": "deals",       "name": "Deals",        "source_type": "deals",      "description": "Products with market discount.", "default_limit": 8, "pool_size": 50, "product_count": 17 }
  ]
}

Fetch a list's products

Pass type=<slug> (or slug=<slug> — alias) to get the token-seeded subset:

curl 'https://amigo.ng/api/commerce/curation/?type=featured&limit=8' \
  -H "X-API-Key: $TOKEN"

Query params:

type   = slug of the list (required for products; omit to discover)
slug   = alias of `type`
limit  = optional override of the list's default_limit
         (capped server-side at the list's pool_size)

Response (200):

{
  "success": true,
  "data": {
    "list": {
      "slug": "featured",
      "name": "Featured",
      "source_type": "manual",
      "description": "Admin-picked highlights.",
      "default_limit": 8,
      "pool_size": 50
    },
    "products": [
      // Same shape as GET /api/commerce/products/ — id, slug, brand, name,
      // description, price, category, stock, rating, images, updated_at
      { "id": 42, "slug": "...", "name": "...", "brand": "oraimo", "price": {...}, "images": [...], … }
    ]
  }
}

Eligibility filters (server-side):

  • List must not be archived.
  • If the list has an active_from / active_until window, the current time must be inside it (lets the team schedule "Black Friday picks" etc.).
  • Only in_stock = true products surface.
  • Pool is capped at the list's pool_size before shuffle; limit is then applied to the shuffle result.
Caching. Responses ship with Cache-Control: private, max-age=60 and Vary: X-API-Key. Safe to cache in the browser; refreshed at most every minute. If you need real-time freshness (e.g. for an admin preview), append a cache-buster query param.

Errors:

404 list_not_found     // slug doesn't exist or list is outside its active window
405 method_not_allowed // only GET is supported
401 invalid_token      // missing or bad X-API-Key

Empty pool (no in-stock products in the list) is 200 with products: [], not a 404 — the list itself exists, it just has nothing to show right now.

States & cities catalog

GET /api/commerce/locations.php Public

Master catalog of the 36 Nigerian states + FCT and their 774 Local Government Areas (called cities in the storefront). Public, no auth, cached 5 minutes — safe to call from a browser checkout flow.

Query parameters:

type        = "states" (default) | "lgas"
state_id    = int                   // optional, when type=lgas
state_code  = "LA" | "KD" | "FCT"   // optional, when type=lgas
search      = "lagos"               // typeahead across both — returns {states:[…], lgas:[…]}

List states:

curl 'https://amigo.ng/api/commerce/locations.php?type=states'
{
  "success": true,
  "data": [
    { "id": 25, "code": "LA", "name": "Lagos",  "capital": "Ikeja",   "region": "South-West",    "sort_order": 250 },
    { "id": 19, "code": "KD", "name": "Kaduna", "capital": "Kaduna",  "region": "North-West",    "sort_order": 190 }
    // …35 more rows
  ],
  "count": 37
}

List cities in a state:

curl 'https://amigo.ng/api/commerce/locations.php?type=lgas&state_code=KD'
{
  "success": true,
  "data": [
    { "id": 142, "state_id": 19, "name": "Kaduna North", "slug": "kaduna-north", "is_capital": 1, "state_code": "KD", "state_name": "Kaduna" },
    { "id": 155, "state_id": 19, "name": "Zaria",        "slug": "zaria",        "is_capital": 0, "state_code": "KD", "state_name": "Kaduna" }
    // …21 more rows
  ],
  "count": 23
}

Typeahead search:

curl 'https://amigo.ng/api/commerce/locations.php?search=zar'
{
  "success": true,
  "query":   "zar",
  "states":  [],
  "lgas":    [
    { "id": 155, "name": "Zaria", "state_code": "KD", "state_name": "Kaduna" }
  ]
}

Delivery quote

POST /api/commerce/delivery/quote/ Public

Quote shipping for a cart at a specific address. Returns prices for both Standard and Home delivery (premium) tiers plus an ETA range, so your checkout can show the customer their options before they commit. Same resolver as create order — the quote you show is what they'll be charged.

Body:

{
  "state_code": "KD",          // or "state_id": 19
  "lga_name":   "Zaria",       // optional · or "lga_id": 155
  "items": [
    { "product_id": 1, "quantity": 2 },
    { "product_id": 5, "quantity": 1 }
  ]
}

Up to 100 line items. quantity isn't used for pricing (delivery is per scheme, not per unit) but is required for shape consistency with /orders/.

curl -X POST https://amigo.ng/api/commerce/delivery/quote/ \
  -H "Content-Type: application/json" \
  --data @quote.json

Success (200):

{
  "success": true,
  "data": {
    "location": {
      "state_id":   19,
      "state_code": "KD",
      "state_name": "Kaduna",
      "lga_id":     155,
      "lga_name":   "Zaria"
    },
    "serviceable": true,
    "schemes": [
      {
        "scheme_id":       1,
        "scheme_name":     "Default Domestic",
        "product_ids":     [1, 5],
        "product_count":   2,
        "all_free":        true,
        "serviceable":     true,
        "tier_resolution": "state",    // 'lga' | 'state' | 'fallback' | 'free'
        "standard": { "price": 1500, "free_for_customer": true,  "eta_min": 0, "eta_max": 2 },
        "premium":  { "price": 5000,                              "eta_min": 0, "eta_max": 2 }
      }
    ],
    "totals":   { "standard": 0, "premium": 5000 },
    "eta":      { "min": 0, "max": 2 },
    "warnings": []
  }
}

Resolution chain — for each unique scheme in the cart, the resolver walks:

1. LGA-specific scheme group covering the customer's LGA   →  tier_resolution: "lga"
2. State-level scheme group covering the customer's state  →  tier_resolution: "state"
3. The scheme's "rest of Nigeria" fallback row             →  tier_resolution: "fallback"
4. No match found                                          →  serviceable: false

Products with no scheme assigned aggregate into a single scheme_id: null bucket and are always free (tier_resolution: "free").

Pricing rules. One charge per unique scheme in the cart (Shopify-style). A scheme's standard tier is free for the customer only when all products in that scheme have free_delivery=true. The premium tier is always paid — free_delivery never zeros it. ETA reported at the cart level is the slowest scheme's range (the order is "delivered" when the last item arrives).

Unserviceable (200 with serviceable=false): when a scheme in the cart doesn't ship to the customer's area, the quote still returns 200 so the UI can show which scheme failed. Order create returns 422 unserviceable_delivery for the same cart — see below.

Create an order

POST /api/commerce/orders/ Idempotent

Create a paid order. Your wallet is debited inline; if the balance is short, the order is not created and you get 422 insufficient_balance. Prices are snapshotted from the catalog at order time, so you can't be surprised by a price change between read and write.

Body:

{
  "customer": {
    "name":  "Aisha Bello",       // required, ≤ 120 chars
    "phone": "08012345678",       // required, ≤ 20 chars
    "email": "aisha@example.com"  // optional
  },
  "delivery": {
    "address":    "Plot 12, Sabon Gari, off PZ Road",  // required
    "state_code": "KD",                 // optional · or "state_id": 19
    "lga_name":   "Zaria",              // optional · or "lga_id": 155
    "tier":       "standard",           // optional · "standard" (default) or "premium"
    "city":       "Zaria",              // optional legacy free-text — auto-filled from lga_name
    "state":      "Kaduna",             // optional legacy free-text — auto-filled from the resolved state
    "notes":      "Call on arrival"
  },
  "items": [
    { "product_id": 42, "quantity": 1 },
    { "product_id": 8,  "quantity": 2 }
  ]
}

Up to 50 distinct line items per order; quantities 1–1000.

Delivery resolution. Pass any of state_id / state_code / lga_id / lga_name in delivery to trigger the scheme-based shipping fee. The server walks the same chain as delivery quote and snapshots the result onto the order. Omit all four for free shipping (legacy back-compat — existing integrations keep working unchanged).

Success (201):

{
  "success": true,
  "data": {
    "id":            1037,
    "reference":     "AMG-COM-20260520-AB12CD",
    "status":        "paid",
    "payment_state": "paid",
    "subtotal":     { "amount": 29000, "display": "₦29,000" },
    "delivery_fee": { "amount":     0, "display": "₦0" },
    "total":        { "amount": 29000, "display": "₦29,000" },
    "customer":     { "name": "Aisha Bello", "phone": "08012345678", "email": "aisha@example.com" },
    "delivery": {
      "address":      "Plot 12, Sabon Gari, off PZ Road",
      "city":         "Zaria",
      "state":        "Kaduna",
      "state_id":     19,
      "lga_id":       155,
      "scheme_id":    1,
      "scheme_name":  "Default Domestic",
      "tier":         "standard",
      "was_free":     true,
      "eta_min_days": 0,
      "eta_max_days": 2,
      "breakdown": [
        { "scheme_id": 1, "scheme_name": "Default Domestic", "tier": "standard", "price": { "amount": 0, "display": "₦0" }, "eta_min_days": 0, "eta_max_days": 2, "product_count": 2 }
      ],
      "notes":        "Call on arrival"
    },
    "items":        [ { "product_id": 42, "name": "Oraimo Traveler 3 …", "quantity": 1, "unit_price": {…}, "line_total": {…} }, … ],
    "created_at":   "2026-05-20 03:12:08",
    "paid_at":      "2026-05-20 03:12:08"
  }
}

On legacy orders (no state_id/lga_id sent), delivery.scheme_id, delivery.tier, delivery.was_free, and delivery.eta_* are all null and breakdown is an empty array.

Unserviceable delivery (422): the cart resolves to a scheme that doesn't ship to the given area, and the scheme has no "rest of Nigeria" fallback configured.

{
  "success":  false,
  "error":    "unserviceable_delivery",
  "message":  "One or more products in the cart can't ship to Zaria, Kaduna. Call POST /api/commerce/delivery/quote/ first to see which.",
  "warnings": ["Scheme 'Lagos Metro Only' does not ship to this area."]
}

Invalid delivery location (422): bad state_id / state_code / lga_id. The response carries a field hint pointing at the offending key.

{
  "success": false,
  "error":   "invalid_delivery_location",
  "field":   "state_code",
  "message": "state_code 'XYZ' is not recognised"
}

Insufficient balance (422):

{
  "success":  false,
  "error":    "insufficient_balance",
  "message":  "Wallet balance is below the order total.",
  "required": 29000,
  "balance":  12500
}
How orders are charged. Pre-fund your Amigo account, then orders just clear against your balance. No card-at-checkout, no per-transaction redirect — your app stays focused on the catalog and your customer.

Get an order

GET /api/commerce/orders/?ref=AMG-COM-… or ?id=N Scoped

Fetch one of your own orders. Another merchant's reference returns 404 not_found.

List orders

GET /api/commerce/orders/ Scoped

Paginated list of YOUR orders, newest first.

ParamTypeNotes
statusstringoptionalpaid | accepted | shipped | delivered | cancelled
payment_statestringoptionalpaid | refunded | unpaid
fromYYYY-MM-DDoptionalcreated_at ≥ this date 00:00:00
toYYYY-MM-DDoptionalcreated_at ≤ this date 23:59:59
qstringoptionalMatches reference, customer_name, customer_phone.
limitintoptionalDefault 20, max 100.
offsetintoptionalDefault 0.
curl "https://amigo.ng/api/commerce/orders/?status=shipped&limit=10" \
  -H "X-API-Key: $TOKEN"

Register a webhook endpoint

POST /api/commerce/webhooks/

Register a URL that should receive event POSTs. The plaintext signing secret is returned once in this response — store it then; later GETs will never include it.

{
  "url":    "https://your-app.com/amigo/webhook",  // required, http(s), ≤ 512 chars
  "events": "*"                                    // "*" all, "order.*" prefix, or "order.shipped,order.delivered"
}
{
  "success": true,
  "data": {
    "id":     12,
    "url":    "https://your-app.com/amigo/webhook",
    "events": "*",
    "status": "active",
    "secret": "a3f1…64-char hex…"     // ← save this NOW
  },
  "message": "Save this secret now — it will not be shown again."
}

List, update, delete

GET /api/commerce/webhooks/

List your registered endpoints (no secrets). Pass ?id=N for one endpoint plus its 20 most recent delivery attempts (status, attempts, response code, scheduled retry).

PATCH /api/commerce/webhooks/?id=N

Update url, events, or status (active/disabled). Disabled endpoints stop receiving new events but keep their delivery history.

DELETE /api/commerce/webhooks/?id=N

Permanently remove the endpoint and its delivery history (FK cascade).

Event reference

EventWhenPayload data
order.created Order was placed and your wallet debited. Full order object
order.accepted Order entered fulfillment. Full order object
order.shipped Order shipped to the customer. Full order object
order.delivered Order was delivered. Full order object
order.cancelled Order was cancelled and your wallet refunded. Full order object incl. cancelled_reason

Wire format:

POST https://your-app.com/amigo/webhook  HTTP/1.1
Host: your-app.com
Content-Type: application/json
User-Agent: Amigo-Commerce-Webhook/1.0
X-Amigo-Event:     order.shipped
X-Amigo-Delivery:  4831
X-Amigo-Timestamp: 1747451291
X-Amigo-Signature: sha256=<hex>

{
  "event":      "order.shipped",
  "livemode":   true,
  "created_at": "2026-05-17T03:48:11+01:00",
  "data":       { …full order… }
}

Verify signatures

Every request includes X-Amigo-Timestamp (unix seconds) and X-Amigo-Signature: sha256=<hex>, computed as hmac_sha256(secret, "$ts.$body"). Verify both — anyone could POST to your URL, and the timestamp keeps an attacker from replaying a captured webhook forever.

Reject any request whose timestamp is more than ~5 minutes in the past.

<?php
$body   = file_get_contents('php://input');
$sig    = $_SERVER['HTTP_X_AMIGO_SIGNATURE'] ?? '';
$ts     = (int) ($_SERVER['HTTP_X_AMIGO_TIMESTAMP'] ?? 0);
$secret = getenv('AMIGO_WEBHOOK_SECRET');  // the one returned at register-time

// Reject stale timestamps (> 5 min skew) — kills replay attacks.
if (abs(time() - $ts) > 300) {
    http_response_code(401);
    exit('stale timestamp');
}

$expected = 'sha256=' . hash_hmac('sha256', $ts . '.' . $body, $secret);
if (!hash_equals($expected, $sig)) {
    http_response_code(401);
    exit('bad signature');
}

$event = json_decode($body, true);
// $event['event']      → "order.shipped"
// $event['data']       → full order object
// $event['created_at'] → ISO 8601
const crypto = require('crypto');

app.post('/amigo/webhook', express.raw({ type: 'application/json' }), (req, res) => {
    const body   = req.body;             // Buffer — verify against bytes!
    const sig    = req.get('X-Amigo-Signature') || '';
    const ts     = parseInt(req.get('X-Amigo-Timestamp') || '0', 10);
    const secret = process.env.AMIGO_WEBHOOK_SECRET;

    // Reject stale timestamps (> 5 min skew).
    if (Math.abs(Date.now() / 1000 - ts) > 300) {
        return res.status(401).send('stale timestamp');
    }

    const signed   = ts + '.' + body.toString();
    const expected = 'sha256=' + crypto.createHmac('sha256', secret).update(signed).digest('hex');
    if (!crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(sig))) {
        return res.status(401).send('bad signature');
    }

    const event = JSON.parse(body.toString());
    // handle event.event …
    res.status(200).send('ok');
});
import hmac, hashlib, time

@app.route('/amigo/webhook', methods=['POST'])
def webhook():
    body   = request.get_data()           # raw bytes, NOT request.json
    sig    = request.headers.get('X-Amigo-Signature', '')
    ts     = int(request.headers.get('X-Amigo-Timestamp', '0'))
    secret = os.environ['AMIGO_WEBHOOK_SECRET'].encode()

    if abs(time.time() - ts) > 300:
        return 'stale timestamp', 401

    signed   = f"{ts}.".encode() + body
    expected = 'sha256=' + hmac.new(secret, signed, hashlib.sha256).hexdigest()
    if not hmac.compare_digest(expected, sig):
        return 'bad signature', 401

    event = json.loads(body)
    # handle event['event'] …
    return 'ok', 200
Sign the bytes, not the parsed JSON. Any framework that re-serializes the body will scramble whitespace and break the signature. Read raw bytes first, verify, then parse.

Retry behaviour

If your endpoint doesn't return a 2xx within 4 seconds, the delivery is queued for retry. The schedule (in seconds since the previous attempt):

  • Attempt 1 → +60s
  • Attempt 2 → +300s (5m)
  • Attempt 3 → +1800s (30m)
  • Attempt 4 → +7200s (2h)
  • Attempt 5 → +43200s (12h)
  • Attempt 6 → +86400s (24h)
  • Attempt 7 → marked exhausted, no further attempts

The X-Amigo-Delivery header is the immutable id of that delivery — use it to dedupe. Two requests with the same delivery id are the same event being retried; idempotent processing on your side is essential.

Ready to integrate? Generate a token in API settings, register a webhook, and try a test order. Your first order.created event will land within seconds.