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
| Type | Fires 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
{
"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:
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.
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))) <= 300Check 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
createdif sequence matters. - Remember imports can be undone. An
import_batch.revertedcan follow animportedevent 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.