Webhooks

Six events - started, each row, completed, failed, and two about credits - signed two ways and retried with backoff.

On this page

Optional, and never required: polling is a first-class path. A webhook only tells you promptly what polling would have told you eventually.

Events #

EventDeliveriesFires when
batch.completedOne per batchEvery item reached a terminal state. Includes a batch in which every item failed - that is completed with succeeded: 0.
batch.failedOne per batchThe batch could not run at all.
batch.startedOne per batchThe first item was claimed: the fleet is working on it now. Carries no results, because there are none yet.
batch.resultOne per rowOne result entry landed, exactly as the cursor returns it. Failed entries included - status tells them apart.
credits.purchasedOne per purchaseCredits were added to the account. Carries the balance they left, which is the figure to act on.
credits.lowOnce per emptyingA charge took the balance under the warning line. Not once per charge: not again until a payment brings it back above.

The first two are the terminal pair and the ones most integrations need. The next two exist because polling answers "is it done" cheaply and cannot answer the other two questions without asking again and again: whether a batch is queued or actually running, and what landed just now.

The last two are about the account rather than a batch, so they carry no batch_id and are never routed by webhook_tag. Subscribe to credits.purchased if a pipeline of yours stops on 402 insufficient_credits: that refusal is not cleared by waiting, so retrying on a schedule retries it forever, and this is the event that says the account can run again.

A callback_url receives the completion. Everything else is a subscription. That is the whole routing rule for the progress events: callback_url names where the answer to one request goes, and a batch of fifty thousand would otherwise dial that URL fifty thousand times because you asked to be told when it finished. To receive batch.started or batch.result, subscribe an endpoint to them in the console.

The body is an envelope - id, event, createdAt and data - and the batch summary is inside data. It is the summary rather than the rows: a 50,000-row batch cannot be an HTTP body, and you already have a cursor that streams them, so the event says "it is done" and you fetch what you want.

JSON
{
  "id": "bdb0a66a-e97c-4ac3-aced-3abb97611877",
  "event": "batch.completed",
  "createdAt": "2026-09-01T10:04:11.531607Z",
  "data": {
    "batch_id": "afec9131-080b-4b96-b3bb-726249e4bbf8",
    "operation": "profiles.enrich",
    "status": "completed",
    "total": 500,
    "succeeded": 494,
    "failed": 6,
    "credits_used": 494,
    "results_available": 500,
    "external_id": "crm-sync-2026-09-01",
    "completed_at": "2026-09-01T10:04:11Z"
  }
}

Read the summary as body.data.batch_id, never body.batch_id. Reading the top level instead is the quietest bug this API can hand you: the signature still verifies, your handler still returns 2xx, and every field is undefined - so nothing anywhere reports an error.

The envelope is camelCase (createdAt) and data is snake_case, for the same reason meta and data split that way on every API response: the envelope is ours and the payload is the record you asked for. It is a split by layer, and it is the same convention in both places.

  • id is the delivery id, the same value as the X-EasyData-Delivery header. It is your idempotency key.
  • event is one of the four above, and is also the X-EasyData-Event header. Switch on it rather than on the shape of data - the shape of data follows from it.
  • createdAt is when the delivery was enqueued. It does not change across retries, so it is not when this attempt was made - that is the t inside the signature headers.
  • data is the batch summary, and carries the rows too when you asked for include_results and the batch fit.

Where it goes #

Three ways to name a destination, most specific first. A batch that named one gets only that one - delivering to the others as well would send the same completion twice to anyone who configured more than one.

On the submissionDelivered to
callback_urlThat URL, and nothing else.
webhook_tagEvery enabled endpoint carrying that tag.
neitherEvery enabled endpoint that has no tags and is subscribed to the event.

Routing by tag #

A tag is a routing key you choose. Tag an endpoint in the console - dev, staging, a customer name - and submit a batch naming it. The completion goes to the endpoints carrying that tag and to no others.

Shell
curl -X POST "https://api.easydata.win/v1/profiles/enrich" \
  -H "X-API-Key: $API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "targets": ["https://linkedin.com/in/satyanadella"],
        "webhook_tag": "dev"
      }'

This is what makes several endpoints worth having. Without it, a developer pointing a tunnel at their laptop receives every colleague's production completion too, and the only way to stop that is to delete the endpoint.

  • Several endpoints can share a tag. All of them receive the delivery - that is the fan-out.
  • An endpoint can carry several tags, up to ten.
  • Tags are lowercased on both sides, so "Dev" and "dev" are one key. Letters, digits, hyphens and underscores.
  • An endpoint with no tags is a default destination and receives every batch that named no tag. Nothing changes for an existing integration until you tag something.
  • callback_url and webhook_tag are mutually exclusive: they are two ways of naming one destination, so sending both is a 400 rather than two deliveries.

A tag matching no enabled endpoint is refused when you submit the batch, not at delivery time. The failure mode of a typo'd routing key is silence, and silence is the hardest thing to debug from your side.

The completion body echoes webhook_tag back, so a receiver shared by several pipelines can tell which key brought the delivery to it.

Knowing it started #

batch.started fires once, when the first item is claimed. It is the difference between "we have your request" and "somebody is working on it", which is the one thing you cannot tell from the outside: a batch queued behind a larger one and a batch being scraped right now look identical to a poller until the first entry lands, and on a busy fleet those are minutes apart.

JSON
{
  "id": "1f0a51b2-1b0b-4a2e-9a2f-2f5e1c0d77aa",
  "event": "batch.started",
  "createdAt": "2026-09-01T10:01:02.114820Z",
  "data": {
    "batch_id": "afec9131-080b-4b96-b3bb-726249e4bbf8",
    "operation": "profiles.enrich",
    "status": "processing",
    "total": 500,
    "external_id": "crm-sync-2026-09-01",
    "started_at": "2026-09-01T10:01:02Z"
  }
}

It carries no counters. A receiver that branched on succeeded rather than on event would read a batch that has just started as one that finished and scraped nothing, so the numbers that do not exist yet are absent rather than zero.

Streaming rows as they land #

batch.result carries one entry, exactly as GET /batches/{id}/results returns it, at the moment it was written. Subscribe to it when something has to act on the first record without waiting for the batch, and read data.result - the entry is two levels down, not one.

JSON
{
  "id": "6c1f9a34-55f0-4a3c-8e47-1b9c0a2d3e4f",
  "event": "batch.result",
  "createdAt": "2026-09-01T10:01:09.902441Z",
  "data": {
    "batch_id": "afec9131-080b-4b96-b3bb-726249e4bbf8",
    "operation": "profiles.enrich",
    "external_id": "crm-sync-2026-09-01",
    "result": {
      "item_index": 0,
      "input": "https://linkedin.com/in/satyanadella",
      "status": "succeeded",
      "credits_used": 1,
      "data": { "full_name": "Satya Nadella" },
      "created_at": "2026-09-01T10:01:09Z"
    }
  }
}
  • One delivery per row. A 50,000-row batch is 50,000 deliveries, which is why nothing subscribes you to this on your behalf. For bulk work the cursor is one request per page of 100 - use it.
  • The unit is the entry, not the target. A paged operation yields one entry per upstream page for a single target, which is why the entry carries page.
  • A failed entry is delivered here too, with status: "failed" and credits_used: 0. There is no separate failure event: one event, and the field inside it that already says which it is.
  • It carries no cursor position. Cursors are opaque and you resume from one we gave you.
  • Deduplicating on X-EasyData-Delivery matters more here than anywhere else, because this is the event you will receive most of.

Headers #

HTTP
X-EasyData-Event: batch.completed
X-EasyData-Delivery: bdb0a66a-e97c-4ac3-aced-3abb97611877
X-EasyData-Attempt: 1
X-EasyData-Timestamp: 1788259260
X-EasyData-Signature: t=1788259260,v1=5257a869e7ecebeda32affa62cdca3fa...
X-EasyData-Signature-Ed25519: t=1788259260,kid=k1,wid=3f8c1e02-9a44-4b71-bd0e-6c2f5a91d7e3,v1b=Xr8sK2...

Every X-EasyData-* header is set by us. A custom header you configure on an endpoint may not start with that prefix, so anything under it can be trusted to have come from here rather than from a header somebody talked you into adding.

X-EasyData-Timestamp carries the same unix seconds as the t inside both signature headers, and it is there for logging and for reading an attempt's age without parsing a signature. Never verify against it. It sits outside both signed messages, so anyone replaying a captured delivery can set it to whatever passes your freshness check; only the signed copy of t cannot be edited without breaking the signature.

Idempotency: dedupe on the delivery id #

X-EasyData-Delivery is stable across every retry of the same delivery. It is your idempotency key: store it, and drop a delivery whose id you have already processed. X-EasyData-Attempt tells you which try this is, and is for logging - never for deciding whether you have seen the event.

You should expect at-least-once delivery. A receiver that returns 2xx after a slow write, and a network that drops our read of that response, produces a second delivery of an event you did handle. Deduping on the delivery id is the only thing that makes that harmless.

Verifying: shared secret #

X-EasyData-Signature is an HMAC-SHA256 over the timestamp and the raw body, keyed with your webhook_secret from GET /account. The timestamp is signed rather than merely sent alongside: without that, a captured request stays replayable forever, because a signature over the body alone never goes stale.

That one secret signs every completion, whichever of the three destinations it went to - including a callback_url you named on a single submission, which needs no standing endpoint and no setup. Read webhook_secret once and verify everything with it. The exception is an endpoint you created yourself in the console: it carries its own secret, shown where you created it.

The signed message is the unix timestamp, a literal dot, then the raw request body - byte for byte, before any JSON parsing.

Python
import hmac, hashlib, time

def verify(secret: str, header: str, body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts, sig = parts["t"], parts["v1"]

    if abs(time.time() - int(ts)) > tolerance:
        return False  # too old to trust: a replay

    expected = hmac.new(
        secret.encode(), f"{ts}.".encode() + body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, sig)
Node
import crypto from "node:crypto";

export function verify(secret, header, body, tolerance = 300) {
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const { t, v1 } = parts;

  if (Math.abs(Date.now() / 1000 - Number(t)) > tolerance) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(t + ".")
    .update(body) // the raw Buffer, before JSON.parse
    .digest("hex");

  return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}
Go
func Verify(secret, header string, body []byte, tolerance time.Duration) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(p, "="); ok {
			parts[k] = v
		}
	}
	ts, err := strconv.ParseInt(parts["t"], 10, 64)
	if err != nil || time.Since(time.Unix(ts, 0)).Abs() > tolerance {
		return false
	}

	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(parts["t"] + "."))
	mac.Write(body)

	return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(parts["v1"]))
}
php
function verify(string $secret, string $header, string $body, int $tolerance = 300): bool {
    parse_str(str_replace(',', '&', $header), $parts);
    if (abs(time() - (int) $parts['t']) > $tolerance) {
        return false;
    }
    $expected = hash_hmac('sha256', $parts['t'] . '.' . $body, $secret);
    return hash_equals($expected, $parts['v1']);
}

Verifying: public key #

X-EasyData-Signature-Ed25519 is made with a private key that never leaves us. Verifying it needs only a public key, published at a stable URL.

That difference matters the moment verification has to happen somewhere you do not fully control. An HMAC secret can forge as well as verify, so handing it to a partner, a queue consumer, a monitoring pipeline or an edge function hands them the ability to mint deliveries that look exactly like ours. A public key cannot do that.

The signed message is the unix timestamp, a dot, the id of the endpoint the delivery was addressed to, another dot, then the raw body. The header carries both of the first two parts, as t and wid.

wid is not decoration, and skipping the check makes the whole verification worthless. One key signs for every customer on this deployment, so a signature naming no destination would say only "this came from EasyData" - and a delivery somebody else legitimately received would verify against your endpoint just as well. Reject any delivery whose wid is not your endpoint id. Your endpoint id is in the body of the test delivery the console sends; there is no API call that hands it back, because endpoints are created and managed in the console rather than through this API. Pin it from there into the configuration of your receiver.

Shell
curl https://api.easydata.win/.well-known/webhook-keys.json

{
  "keys": [
    { "kid": "k1", "alg": "ed25519", "public_key": "2o9gyHX8xCUmjaF4R-40ENeM9CvkhkvUCs1I4h2p00c", "use": "webhook-signature" }
  ]
}
Python
import base64, time
from nacl.signing import VerifyKey   # pip install pynacl

def verify_ed25519(public_key_b64: str, webhook_id: str, header: str,
                   body: bytes, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts, wid, sig = parts["t"], parts.get("wid", ""), parts["v1b"]

    if abs(time.time() - int(ts)) > tolerance:
        return False
    if not wid or wid != webhook_id:
        return False  # addressed to somebody else's endpoint: a replay

    key = VerifyKey(base64.urlsafe_b64decode(public_key_b64 + "=="))
    try:
        key.verify(f"{ts}.{wid}.".encode() + body, base64.urlsafe_b64decode(sig + "=="))
        return True
    except Exception:
        return False
Go
func VerifyEd25519(publicKeyB64, webhookID, header string, body []byte) bool {
	parts := map[string]string{}
	for _, p := range strings.Split(header, ",") {
		if k, v, ok := strings.Cut(p, "="); ok {
			parts[k] = v
		}
	}
	// Addressed to somebody else's endpoint is a replay, not a delivery.
	if parts["wid"] == "" || parts["wid"] != webhookID {
		return false
	}
	pub, err := base64.RawURLEncoding.DecodeString(publicKeyB64)
	if err != nil {
		return false
	}
	sig, err := base64.RawURLEncoding.DecodeString(parts["v1b"])
	if err != nil {
		return false
	}
	msg := append([]byte(parts["t"]+"."+parts["wid"]+"."), body...)
	return ed25519.Verify(pub, msg, sig)
}

kid names which key signed the delivery. Rotation publishes the new key beside the old one and keeps signing with the old one until receivers have picked both up, so a rotation is never a moment where every verification fails at once. Match on kid rather than assuming there is one key. v1b names the scheme, and a receiver matching on it fails loudly rather than verifying a version it was not written for.

Retries #

  • Any 2xx acknowledges. A 408, a 429, a 5xx or a connection failure is retried on a growing schedule for a little over four hours; any other 4xx is terminal, because it will answer identically on every attempt.
  • The delivery id is the same across every attempt. X-EasyData-Attempt counts them.
  • An endpoint that fails repeatedly is disabled, and the console says so rather than leaving you to guess why deliveries stopped.
  • Respond fast and do the work afterwards. A receiver that finishes its own processing before answering is a receiver that will eventually time out and be retried.

Your endpoint must be public #

A callback URL is checked when you save it and again when we dial it. Loopback, LAN and link-local addresses are refused both times - a hostname that resolves publicly when it is saved can resolve to a metadata service by the time it is dialled, so one check is not enough.