Developer documentation

Ethio-Viral API

Gift cards, airtime and eSIM · premium subscription seats · SMM services. Around 17,000 things to sell — one API key for all three.

Base https://api.ethio-viral.com/v1 Spec openapi.json For AI llms.txt Keys get one →

Ask AI

Open your assistant with the whole API pre-loaded — it reads the spec and writes the integration for you.

Get started

1 — Create a key

Create a key in whichever place you already use — both make the same key:

The key is shown once — store it immediately. Revoke it from the same place at any time.

One key, all three products. The same key opens Store, Premium and SMM, spending one wallet. Keys begin evk_ or evk_live_ depending on where you made them; both work everywhere, and there is nothing to migrate if you already hold one.

Using an AI assistant? Install @ethioviral/mcp and your assistant can browse the catalog and place orders for you — Claude Desktop, Cursor, or anything that speaks MCP:

{
  "mcpServers": {
    "ethio-viral": {
      "command": "npx",
      "args": ["-y", "@ethioviral/mcp"],
      "env": { "ETHIOVIRAL_API_KEY": "evk_YOUR_KEY" }
    }
  }
}

Same key, same limits, all three products. Package on npm.

Assistant that connects by URL? ChatGPT and other remote MCP clients use our hosted server — nothing to install:

https://api.ethio-viral.com/mcp

Without a key it is read-only: catalog, prices, SMM rates and premium products. Send your key and every tool unlocks, with orders paid from that key's own wallet:

Authorization: Bearer evk_YOUR_KEY
# or
X-API-Key: evk_YOUR_KEY

2 — Send it on every request

Authorization: Bearer evk_YOUR_KEY
# or
X-API-Key: evk_YOUR_KEY

3 — Your first order, in three calls

# find something
curl -s "https://api.ethio-viral.com/v1/catalog/search?q=netflix" \
  -H "Authorization: Bearer evk_YOUR_KEY"

# read the exact denominations you may order
curl -s https://api.ethio-viral.com/v1/catalog/product/uq-ABC123 \
  -H "Authorization: Bearer evk_YOUR_KEY"

# buy one — Idempotency-Key is required
curl -s -X POST https://api.ethio-viral.com/v1/orders \
  -H "Authorization: Bearer evk_YOUR_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: your-own-unique-id-001" \
  -d '{"productId":"uq-ABC123","denomination":199000,"recipient":"buyer@example.com"}'

Then poll GET /v1/orders/{id} until status is fulfilled and read deliveryPayload — the code, link or voucher.

Store — gift cards, airtime, eSIM

GET/v1/catalog

Every product we resell, paged. The complete supplier catalog — over 10,000 products — not the curated shelves the storefront shows.

QueryMeaning
page1-based. Default 1.
per_pageDefault 200, maximum 1000.
sinceEpoch ms — only products changed since then. See below.
curl -s "https://api.ethio-viral.com/v1/catalog?page=1&per_page=500" -H "Authorization: Bearer evk_YOUR_KEY"

{
  "products": [ ... ],
  "page": 1, "per_page": 500,
  "total_items": 10000, "total_pages": 20,
  "complete": true,
  "synced_at": 1789344151000
}

complete: false means a supplier sync is still running or a page failed. The products you got are valid — there are simply more coming. If you mirror our catalog, re-sync rather than deleting what is missing.

Only what changed — ?since=

Store the synced_at from your last pull and send it back. You get only the products that actually moved — usually a handful of prices following the exchange rate, instead of 20 pages.

curl -s "https://api.ethio-viral.com/v1/catalog?since=1789344151000" -H "Authorization: Bearer evk_YOUR_KEY"

{ "products": [ ... ], "delta": true, "since": 1789344151000, "synced_at": 1789431000000 }

Sync once in full, then poll with ?since=, saving the new synced_at each time.

GET/v1/catalog/search?q=

Live full-text search across the supplier catalog.

GET/v1/catalog/categories

Categories with product counts.

GET/v1/catalog/category/{cat}

Products in one category.

GET/v1/catalog/sections

The storefront’s merchandising shelves.

GET/v1/catalog/section/{key}

One shelf. Optional ?country=ET filter.

GET/v1/catalog/product/{id}

Call this before ordering. It returns the exact denoms[].retailMinor values that POST /v1/orders accepts, and the recipientType telling you what the buyer must supply.

Product shape

{
  "id": "uq-ZLCYydbpQQPeIo",
  "name": "Ethiotelecom Ethiopia Monthly Unlimited …",
  "brand": "Ethiotelecom Ethiopia",
  "country": "ET",
  "productType": "airtime",
  "recipientType": "phone_number",
  "instant": true,
  "inStock": null,
  "image": "https://api.ethio-viral.com/brands/premium/v2/netflix.webp",
  "denoms": [
    { "label": "$10.53", "face": 10.527, "faceCurrency": "USD", "retailMinor": 199000 }
  ]
}
FieldMeaning
retailMinorWhat you pay, in ETB santim. 199000 = ETB 1,990.00. Send this exact integer as denomination.
face · faceCurrencyThe value printed on the product. Display only — never send it as the price, never convert it yourself.
recipientTypeemail, phone_number or none — what recipient must contain.
inStocktrue in stock · false out of stock · null we do not track stock for it. Never treat null as available.
fieldsExtra values this product needs, e.g. ["playerid"]. Send them in fields when ordering.
instanttrue delivers in seconds; otherwise expect minutes.
imageAbsolute and directly loadable.

Orders

POST/v1/orders

Creates an order and debits your wallet.

Idempotency-Key is required. Without it a retried request after a timeout would buy twice. Reusing the same key with the same body is safe and returns the original order — so retry freely, but never reuse a key for a different purchase.

FieldRequiredMeaning
productIdyesFrom the catalog.
denominationyesA retailMinor from that product’s denoms.
recipientusuallyEmail, phone or account id, per recipientType.
quantitynoDefaults to 1.
fieldsnoExtra supplier values when a product asks for them.
GET/v1/orders

Your orders, newest first.

GET/v1/orders/{id}

One order. Poll until status is fulfilled, then read deliveryPayload. There is no separate delivery endpoint and there are no webhooks.

{ "order": { "id": "...", "status": "fulfilled", "deliveryPayload": "XXXX-YYYY-ZZZZ" } }
paid → processing → fulfilled
                 └→ refunded      (straight back to your wallet)

You are never charged for an order that fails.

Premium — subscription seats

Netflix, Spotify, ChatGPT, Canva, Adobe and similar. 69 products. Same key, but premium settles in USD while the store settles in ETB — check /v1/premium/balance before ordering.

GET/v1/premium/products

The premium catalogue with your tier pricing and stock.

GET/v1/premium/products/{id}

One product.

GET/v1/premium/balance

Your premium wallet, in USD.

POST/v1/premium/orders

Order a seat. Idempotency-Key required, same rule as the store.

GET/v1/premium/orders/{id}

One order — poll until fulfilled.

GET/v1/premium/orders/{id}/delivery

Collect the code or invite link. Unlike the store, premium delivery is a separate call.

curl -s -X POST https://api.ethio-viral.com/v1/premium/orders \
  -H "Authorization: Bearer evk_YOUR_KEY" \
  -H "Idempotency-Key: premium-001" \
  -d '{"productId":"prm_...","quantity":1,"recipient":"buyer@example.com"}'

SMM — followers, views, likes

6,981 services across Instagram, TikTok, YouTube, Telegram and more. SMM works differently from the other two: you send a service, a link and a quantity — not a product and a denomination.

GET/v1/smm/services

Every service with your rate per 1,000, and the min/max quantity it accepts.

POST/v1/smm/orders

Create an order. Quantity must sit between that service’s min and max.

GET/v1/smm/orders/{id}

Real progress — start_count and remains from the provider, not an assumed 100%.

curl -s -X POST https://api.ethio-viral.com/v1/smm/orders \
  -H "Authorization: Bearer evk_YOUR_KEY" \
  -H "Idempotency-Key: smm-001" \
  -d '{"serviceId":"1830","link":"https://instagram.com/username","quantity":1000}'

502 provider_rejected_refunded — handle this one. The supplier refused the order and your wallet has already been refunded. It is a clean failure, not a timeout: do not retry with the same Idempotency-Key, and do not tell your customer it succeeded.

CodeMeaning
400Quantity outside the service’s min/max, or a subscription missing posts / quantity per post. The error says which.
404service_not_found — no active service with that id.
409idempotency_key_payload_mismatch — that key was already used with a different body.
502provider_rejected_refunded — refused upstream, money already returned.

POST /v1/smm/orders also returns the full order object alongside order_id, so you can show the customer what they bought without a second call. GET /v1/smm/orders/{id} returns action as well, telling you whether that check reconciled anything.

Some services support refill and cancel. Each service in /v1/smm/services carries a refill and cancel flag — check them before offering either to your customer.

Webhooks — stop polling

Tell us where to POST, and we deliver the moment an order finishes. This replaces polling GET /v1/orders/{id} in a loop.

PUT/v1/webhook

Set your endpoint. Returns the signing secret once.

curl -s -X PUT https://api.ethio-viral.com/v1/webhook \
  -H "Authorization: Bearer evk_YOUR_KEY" \
  -d '{"url":"https://your-server.com/hooks/ethio-viral"}'

{ "webhook": {...}, "secret": "evs_...", "note": "Copy this signing secret now. It is not shown again." }

https only, and private or loopback addresses are refused — a webhook URL is a request we make from our server, so pointing it inward is not allowed.

GET/v1/webhook

Your endpoint, plus delivery health: last status, last success, consecutive failures.

POST/v1/webhook/test

Sends a real signed delivery, so you can prove your verification works before a live order depends on it.

DELETE/v1/webhook

Remove it and go back to polling.

What we send

POST your-url
x-ethio-viral-timestamp: 1789400000
x-ethio-viral-signature: sha256=...

{ "event": "order.fulfilled", "created": 1789400000,
  "data": { "id": "...", "status": "fulfilled", "deliveryPayload": "XXXX-YYYY",
            "amount": { "currency": "ETB", "minor": 199000 } } }

Events: order.fulfilled · order.refunded · order.failed. Only terminal states — we do not send one per intermediate step.

Verify the signature

HMAC-SHA256 over timestamp + "." + raw body, using your secret. Verify against the raw bytes, before any JSON parse and re-serialise, or key ordering will break it.

const expected = crypto.createHmac('sha256', SECRET)
  .update(req.headers['x-ethio-viral-timestamp'] + '.' + rawBody)
  .digest('hex')
const ok = crypto.timingSafeEqual(
  Buffer.from(signature.replace('sha256=', ''), 'hex'),
  Buffer.from(expected, 'hex'))

Reject anything whose timestamp is more than 5 minutes from your clock — that is what stops a captured delivery being replayed.

We retry 5xx and 429 about five times over ~20 minutes, backing off each time. We never retry a 4xx — that means the URL or payload is wrong, and retrying would just hammer your server. Reply 2xx quickly and do your work after.

Balance

GET/v1/balance

Your wallet in ETB santim. Orders are paid from it, so top up before a large batch.

Errors

Every error is {"error":"..."}.

CodeMeaningWhat to do
400Bad productId or denominationRe-read the product; denominations change.
401Missing, invalid or revoked keyCheck the header and that the key is still active.
402Not enough balanceTop up.
403Someone else’s orderYou can only read your own.
404Unknown product or order—
409Out of stock or unavailableRetry later or pick another denomination.
429Rate limitedBack off and retry after the minute.

Rate limits

60 requests per minute for POST /v1/orders and 30 for /v1/catalog/search. Catalog reads are cached — page through once, then keep it current with ?since=.

Every response tells you where you stand, so you never have to guess:

x-ratelimit-limit: 1500
x-ratelimit-remaining: 1499
x-ratelimit-reset: 60