API Webhooks

Webhooks tell you what the merchant did. Polling import_status works, but a webhook means you find out within a minute instead of on your next sweep.

There Is No created Event

You will not find revenue.created here, and that is deliberate. You created it; you already know. Every event on this page is something you could not otherwise have learned without asking.

Event Types

TypeFires when
customer.imported A customer you sent reached the merchant's books.
customer.rejected A merchant reviewed a customer you sent and declined it.
supplier.imported A supplier you sent reached the merchant's books.
supplier.rejected A merchant reviewed a supplier you sent and declined it.
category.imported A category you sent reached the merchant's books.
category.rejected A merchant reviewed a category you sent and declined it.
product.imported A product you sent reached the merchant's books.
product.rejected A merchant reviewed a product you sent and declined it.
expense.imported A expense you sent reached the merchant's books.
expense.rejected A merchant reviewed a expense you sent and declined it.
revenue.imported A revenue you sent reached the merchant's books.
revenue.rejected A merchant reviewed a revenue you sent and declined it.
refund.imported A refund you sent reached the merchant's books.
refund.rejected A merchant reviewed a refund you sent and declined it.
import_batch.completed A merchant imported a batch of objects.
import_batch.reverted A merchant undid an import. Its objects are pending again.

An import fires one event per object and one import_batch.completed. Subscribe to whichever granularity suits you; most integrations want the per-object events, since a batch may also contain fifty objects from somebody else's app.

Registering an Endpoint

curl https://argorobots.com/v1/webhook_endpoints \
  -H "Authorization: Bearer ab_..." \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: hook-1" \
  -d '{
    "url": "https://example.com/hooks/argo",
    "enabled_events": ["revenue.imported", "revenue.rejected"],
    "description": "Production"
  }'
$payload = json_encode([
    'url'            => 'https://example.com/hooks/argo',
    'enabled_events' => ['revenue.imported', 'revenue.rejected'],
    'description'    => 'Production',
]);

$ch = curl_init('https://argorobots.com/v1/webhook_endpoints');
curl_setopt_array($ch, [
    CURLOPT_POST           => true,
    CURLOPT_POSTFIELDS     => $payload,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => [
        'Authorization: Bearer ab_...',
        'Content-Type: application/json',
        'Idempotency-Key: hook-1',
    ],
]);

$endpoint = json_decode(curl_exec($ch), true);

// The only time signing_secret is ever returned. Store it now.
echo $endpoint['signing_secret'];
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", "ab_...");

using var request = new HttpRequestMessage(
    HttpMethod.Post, "https://argorobots.com/v1/webhook_endpoints")
{
    Content = JsonContent.Create(new
    {
        url = "https://example.com/hooks/argo",
        enabled_events = new[] { "revenue.imported", "revenue.rejected" },
        description = "Production",
    }),
};
request.Headers.Add("Idempotency-Key", "hook-1");

using var response = await http.SendAsync(request);
var endpoint = await response.Content.ReadFromJsonAsync<JsonElement>();

// The only time signing_secret is ever returned. Store it now.
Console.WriteLine(endpoint.GetProperty("signing_secret").GetString());
const res = await fetch("https://argorobots.com/v1/webhook_endpoints", {
  method: "POST",
  headers: {
    Authorization: "Bearer ab_...",
    "Content-Type": "application/json",
    "Idempotency-Key": "hook-1",
  },
  body: JSON.stringify({
    url: "https://example.com/hooks/argo",
    enabled_events: ["revenue.imported", "revenue.rejected"],
    description: "Production",
  }),
});

const endpoint = await res.json();

// The only time signing_secret is ever returned. Store it now.
console.log(endpoint.signing_secret);
import requests

endpoint = requests.post(
    "https://argorobots.com/v1/webhook_endpoints",
    headers={
        "Authorization": "Bearer ab_...",
        "Idempotency-Key": "hook-1",
    },
    json={
        "url": "https://example.com/hooks/argo",
        "enabled_events": ["revenue.imported", "revenue.rejected"],
        "description": "Production",
    },
).json()

# The only time signing_secret is ever returned. Store it now.
print(endpoint["signing_secret"])

Omit enabled_events, or pass ["*"], to receive everything.

The response contains signing_secret. That is the only time it is returned. Store it before you close the terminal.

The URL must be public HTTPS. Plain HTTP, localhost, and anything resolving to a private or link-local address are refused, because otherwise this endpoint would let anyone with a key aim signed requests at our internal network.

Manage endpoints with GET, POST and DELETE on /v1/webhook_endpoints and /v1/webhook_endpoints/<id>. Pass status as enabled or disabled to pause one without deleting it.

The Payload

JSON
{
  "id": "evt_37dfeee7e5d47d2eac5346b9",
  "object": "event",
  "type": "revenue.imported",
  "created": 1787091029,
  "data": {
    "object": {
      "id": "rev_136ace96eaf4d428c8248b8f",
      "object": "revenue",
      "description": "Order #1042",
      "amount": 11300,
      "currency": "USD",
      "occurred_on": "2026-08-14",
      "import": {
        "status": "imported",
        "batch": "imb_21cf047240398e8c8f1c661f",
        "local_ref": "REV-2026-00087"
      }
    }
  }
}

data.object is the full object exactly as the API would return it, captured at the moment of the event.

Verifying the Signature

Every delivery carries a header:

HTTP
Argo-Signature: t=1787091029,v1=5f2c...9ab1

v1 is HMAC-SHA256(secret, "<t>.<raw body>"). Sign the raw body bytes, before any JSON parsing; re-serialising first will not match.

Verifying a delivery
function argo_signature_is_valid(string $body, string $header, string $secret): bool
{
    if (!preg_match('/t=(\d+),v1=([0-9a-f]+)/', $header, $m)) {
        return false;
    }

    $expected = hash_hmac('sha256', $m[1] . '.' . $body, $secret);

    // hash_equals, not ===, so the comparison cannot be timed.
    return hash_equals($expected, $m[2])
        && abs(time() - (int) $m[1]) <= 300;
}
static bool ArgoSignatureIsValid(string body, string header, string secret)
{
    var match = Regex.Match(header, @"t=(\d+),v1=([0-9a-f]+)");
    if (!match.Success) return false;

    var timestamp = long.Parse(match.Groups[1].Value);

    using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
    var expected = Convert.ToHexString(
        hmac.ComputeHash(Encoding.UTF8.GetBytes($"{timestamp}.{body}"))).ToLowerInvariant();

    // Fixed-time comparison, so the check cannot be timed.
    var signatureOk = CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(expected),
        Encoding.UTF8.GetBytes(match.Groups[2].Value));

    var age = Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - timestamp);
    return signatureOk && age <= 300;
}
import crypto from "node:crypto";

export function argoSignatureIsValid(body, header, secret) {
  const match = /t=(\d+),v1=([0-9a-f]+)/.exec(header);
  if (!match) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${match[1]}.${body}`)
    .digest("hex");

  // timingSafeEqual, so the comparison cannot be timed. It throws when the
  // lengths differ, which is why they are checked first.
  const signatureOk =
    expected.length === match[2].length &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(match[2]));

  const age = Math.abs(Date.now() / 1000 - Number(match[1]));
  return signatureOk && age <= 300;
}
import hashlib
import hmac
import re
import time


def argo_signature_is_valid(body: str, header: str, secret: str) -> bool:
    match = re.search(r"t=(\d+),v1=([0-9a-f]+)", header)
    if not match:
        return False

    expected = hmac.new(
        secret.encode(),
        f"{match.group(1)}.{body}".encode(),
        hashlib.sha256,
    ).hexdigest()

    # compare_digest, so the comparison cannot be timed.
    if not hmac.compare_digest(expected, match.group(2)):
        return False

    return abs(time.time() - int(match.group(1))) <= 300

Check the timestamp as well as the signature. The timestamp is inside the signed material, so it cannot be edited, and rejecting anything older than a few minutes stops a captured delivery being replayed at you later.

Retries

Any 2xx is success. Anything else is retried up to six times over about 15 hours: immediately, then after 1 minute, 5 minutes, 30 minutes, 2 hours, and 12 hours.

An endpoint whose last 20 deliveries all failed is disabled automatically. Re-enable it with POST /v1/webhook_endpoints/<id> and {"status":"enabled"} once your receiver is fixed.

Redirects are never followed. The signature belongs to the URL the merchant approved.

Writing a Receiver That Behaves

  • Reply 200 immediately, work afterwards. The delivery times out after 10 seconds. Queue the event and return.
  • Expect duplicates. A delivery that succeeds after your server has already processed it but before the response reaches us will arrive again. Deduplicate on the event id.
  • Do not assume order. Retries mean an older event can land after a newer one. Use created if sequence matters.
  • Remember imports can be undone. An import_batch.reverted can follow an imported event for the same object.

Catching Up After an Outage

You do not need us to replay anything. Every event is readable from the log for 90 days:

curl "https://argorobots.com/v1/events?type=revenue.imported&limit=100" \
  -H "Authorization: Bearer ab_..."
$url = 'https://argorobots.com/v1/events?type=revenue.imported&limit=100';

$ch = curl_init($url);
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ab_...'],
]);

$events = json_decode(curl_exec($ch), true)['data'];
echo count($events), ' events';
using var http = new HttpClient();
http.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", "ab_...");

var page = await http.GetFromJsonAsync<JsonElement>(
    "https://argorobots.com/v1/events?type=revenue.imported&limit=100");

var events = page.GetProperty("data").GetArrayLength();
Console.WriteLine($"{events} events");
const res = await fetch(
  "https://argorobots.com/v1/events?type=revenue.imported&limit=100",
  { headers: { Authorization: "Bearer ab_..." } },
);

const { data: events } = await res.json();
console.log(`${events.length} events`);
import requests

events = requests.get(
    "https://argorobots.com/v1/events",
    params={"type": "revenue.imported", "limit": 100},
    headers={"Authorization": "Bearer ab_..."},
).json()["data"]

print(len(events), "events")

It paginates like any other list, so you can walk back to wherever you stopped.

Esc