links.et

Integration flows

Three ways to consume /api/verify (short wait, polling, and SSE) plus idempotency replay. Pick whichever matches how your app handles latency.

POST /api/verify defaults to synchronous: the call blocks until the upstream finishes, then returns the receipt. For most callers (a single on-demand verification triggered by a user click) that's fine.

When you can't or don't want to hold the request open (mobile network, server with strict request budgets, queue-of-many architecture), use one of the three async-aware flows below. All three share the same submit endpoint and result envelope; what differs is how the client waits.

Short Wait

Submit with waitMs to ask the server to block up to waitMs milliseconds. If the verification finishes before the deadline, you get the same 200/502 response a sync call would return. If it doesn't, you get 202 + a request id and the URLs you'd use to poll or stream.

curl -sX POST https://links.et/api/verify \
  -H "x-api-key: vk_live_..." \
  -H "content-type: application/json" \
  -d '{
    "url": "https://transactioninfo.ethiotelecom.et/receipt/ABCD1234EF",
    "waitMs": 3000
  }'

Completed inside the wait window: 200

{
  "ok": true,
  "processingStatus": "completed",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "providerKey": "telebirr",
  "resolvedUrl": "...",
  "receipt": { "...": "..." },
  "cached": false,
  "httpStatus": 200,
  "error": null
}

Still running at the deadline: 202

{
  "processingStatus": "queued",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "statusUrl": "/api/verify/550e8400-e29b-41d4-a716-446655440000",
  "eventsUrl": "/api/verify/550e8400-e29b-41d4-a716-446655440000/events"
}

The verification keeps running on the server. Follow up via polling or SSE; both work against the same requestId.

waitMs is capped at 30 000 ms server-side; anything higher is silently clamped.

Polling

Use when your environment can't keep a long-lived connection open but you can call back every few seconds.

while true; do
  RESP=$(curl -s -H "x-api-key: vk_live_..." \
    "https://links.et/api/verify/$REQUEST_ID")
  STATUS=$(jq -r '.processingStatus' <<< "$RESP")
  if [ "$STATUS" = "completed" ] || [ "$STATUS" = "failed" ]; then
    echo "$RESP" | jq .
    break
  fi
  sleep 2
done

While the request is still queued: 202

{
  "processingStatus": "queued",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "statusUrl": "/api/verify/...",
  "eventsUrl": "/api/verify/.../events"
}

Once the row resolves: 200 / 502

Same envelope as the sync flow, with processingStatus and requestId added:

{
  "ok": true,
  "processingStatus": "completed",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "providerKey": "telebirr",
  "receipt": { "...": "..." },
  "cached": false,
  "httpStatus": 200,
  "error": null
}

Cadence suggestion: 1.5 to 3 s between polls. Faster than one per second buys nothing, because the verification runs at its own pace regardless.

A poll reads an existing row, so it counts against your per-minute request rate but never against your plan's verification cap. Only the original submit can spend an uncached verification, and only if the receipt was not already cached. See rate limits for the difference.

Server-Sent Events (SSE)

Use when the client can keep a connection open and wants the result as soon as it's ready (instead of polling on a timer).

curl -N \
  -H "Accept: text/event-stream" \
  "https://links.et/api/verify/$REQUEST_ID/events?x-api-key=vk_live_..."

EventSource from the browser:

const es = new EventSource(
  `/api/verify/${requestId}/events?x-api-key=${apiKey}`,
);
 
es.addEventListener('status', (e) => {
  const d = JSON.parse(e.data);
  console.log('status:', d.status);
});
 
es.addEventListener('completed', (e) => {
  const d = JSON.parse(e.data);
  console.log('done', d.receipt);
  es.close();
});
 
es.addEventListener('failed', (e) => {
  const d = JSON.parse(e.data);
  console.error('failed', d.error);
  es.close();
});
 
es.addEventListener('timeout', () => {
  // The server caps the stream at 5 min. Reconnect to pick up where you
  // left off; pending state is replayed on connect.
  es.close();
});

Event types

EventWhenPayload
statusWhen the row is created (pending) and on any intermediate transition{ type: 'status', status: 'pending' | 'processing', ts }
completedVerification finished successfully. Stream closes after.{ type: 'completed', ok: true, cached, receipt, httpStatus, ts }
failedVerification finished with an error. Stream closes after.{ type: 'failed', error, httpStatus, ts }
timeoutThe server is closing the stream at the 5-minute lifetime cap.{ type: 'timeout', message }

The stream also sends an unnamed comment (: ping) every 15 s to keep proxies from idling the connection.

Replay-on-reconnect

If the verification resolves between your submit call and the moment you subscribe to the SSE stream, you don't miss the terminal event; the server replays the last-seen event on connect for ~5 minutes. After that, the in-memory replay is evicted (the row stays in Postgres; the GET polling endpoint still returns the resolution forever).

Idempotency

Add an Idempotency-Key header to POST /api/verify to make retries safe. The first call with a given key (per API key) creates a request and returns its id. Any subsequent call with the same key replays the current state of that same request; even if the URL differs in the retry, you get the original.

curl -X POST https://links.et/api/verify \
  -H "x-api-key: vk_live_..." \
  -H "Idempotency-Key: 7e1a4c34-..." \
  -H "content-type: application/json" \
  -d '{"url":"...","waitMs":2000}'

A 200 / 202 / 502 will look identical to the first response, including the same requestId, for as long as the row exists. Use a UUID (or any stable string up to 256 chars) generated by the client per logical operation. Idempotency rows are kept indefinitely; there's no TTL today.

When to use which

FlowBest forTrade-off
Sync (default)One-off verifications triggered by user interaction; cached receiptsHolds the HTTP request open. Instant on a cache hit, but see below for the worst case
Short waitCached-most-of-the-time receipts where you accept queue fallback when it isn't cachedUp to 30 s of held connection in the worst case
PollingBackground jobs, queue workers, mobile networksSlightly stale; uses more HTTP requests
SSEBrowser UIs that want the result instantlyNeeds a long-lived connection; harder to put behind some proxies

You can mix and match. A common pattern is: submit with waitMs: 2000. If 202, open SSE for the result. If your SSE connection drops, fall back to polling once a second.

What a busy bank does to a sync call

A cached receipt returns immediately. An uncached one has to reach the bank, and we are a polite client: each upstream host gets a token bucket per egress identity, so when a bank is saturated your call waits for a slot before the fetch even starts.

That makes the sync worst case much longer than one fetch timeout:

StageBound
Waiting for an upstream slot30 s, longer on a deliberately slow host, hard ceiling 120 s
The fetch itself20 s per attempt
One retry, on a different egress identity20 s

So a sync call against a struggling bank can hold the connection for well over a minute. There is one retry, never more, and none at all for a metered link like Siinqee.

If the queue is full or the wait runs out, the call gives up rather than hanging, and the error reads like gave up waiting 34s for a slot on transactioninfo.ethiotelecom.et. Today that returns HTTP 400, because the response never got an upstream status code to report. It is a "come back later", not a complaint about your request, so retry it with backoff the way you would a 502.

This is the reason waitMs exists. Submit with a deadline you are happy to hold, take the 202, and pick the result up by polling or SSE instead of holding a socket open through a bank outage.

On this page