Get started
1 — Create a key
Create a key in whichever place you already use — both make the same key:
- ethio-viral.com/api-keys — the website
- store.ethio-viral.com/profile/api-keys — the storefront
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_KEY2 — 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
Every product we resell, paged. The complete supplier catalog — over 10,000 products — not the curated shelves the storefront shows.
| Query | Meaning |
|---|---|
page | 1-based. Default 1. |
per_page | Default 200, maximum 1000. |
since | Epoch 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.
Live full-text search across the supplier catalog.
Categories with product counts.
Products in one category.
The storefront’s merchandising shelves.
One shelf. Optional ?country=ET filter.
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 }
]
}
| Field | Meaning |
|---|---|
retailMinor | What you pay, in ETB santim. 199000 = ETB 1,990.00. Send this exact integer as denomination. |
face · faceCurrency | The value printed on the product. Display only — never send it as the price, never convert it yourself. |
recipientType | email, phone_number or none — what recipient must contain. |
inStock | true in stock · false out of stock · null we do not track stock for it. Never treat null as available. |
fields | Extra values this product needs, e.g. ["playerid"]. Send them in fields when ordering. |
instant | true delivers in seconds; otherwise expect minutes. |
image | Absolute and directly loadable. |
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.
| Field | Required | Meaning |
|---|---|---|
productId | yes | From the catalog. |
denomination | yes | A retailMinor from that product’s denoms. |
recipient | usually | Email, phone or account id, per recipientType. |
quantity | no | Defaults to 1. |
fields | no | Extra supplier values when a product asks for them. |
Your orders, newest first.
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.
The premium catalogue with your tier pricing and stock.
One product.
Your premium wallet, in USD.
Order a seat. Idempotency-Key required, same rule as the store.
One order — poll until fulfilled.
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.
Every service with your rate per 1,000, and the min/max quantity it accepts.
Create an order. Quantity must sit between that service’s min and max.
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.
| Code | Meaning |
|---|---|
400 | Quantity outside the service’s min/max, or a subscription missing posts / quantity per post. The error says which. |
404 | service_not_found — no active service with that id. |
409 | idempotency_key_payload_mismatch — that key was already used with a different body. |
502 | provider_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.
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.
Your endpoint, plus delivery health: last status, last success, consecutive failures.
Sends a real signed delivery, so you can prove your verification works before a live order depends on it.
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
Your wallet in ETB santim. Orders are paid from it, so top up before a large batch.
Errors
Every error is {"error":"..."}.
| Code | Meaning | What to do |
|---|---|---|
400 | Bad productId or denomination | Re-read the product; denominations change. |
401 | Missing, invalid or revoked key | Check the header and that the key is still active. |
402 | Not enough balance | Top up. |
403 | Someone else’s order | You can only read your own. |
404 | Unknown product or order | — |
409 | Out of stock or unavailable | Retry later or pick another denomination. |
429 | Rate limited | Back 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