fana.jstnlt.my.id
API reference

Disposable inboxes, over HTTP

Create an address, send something to it, and block until the mail arrives — then read the one-time code straight out of the body. Built for the part of a test suite that has to receive an email.

Base URL

Every path on this page is relative to this instance. A deployment you run yourself has its own, so read it from configuration rather than hard-coding fana.jstnlt.my.id.

https://fana.jstnlt.my.id

Quickstart

Sign in, create a key in the dashboard, and the three calls below are the whole flow.

# 1. Mint an inbox — private, only your keys can read it
ADDR=$(curl -s -X POST https://fana.jstnlt.my.id/v1/inboxes \
  -H "Authorization: Bearer $FANA_KEY" \
  -H "content-type: application/json" -d '{}' | jq -r .address)

# 2. Trigger whatever sends the mail, using $ADDR

# 3. Wait for it, and read the code straight out of the body
curl -s "https://fana.jstnlt.my.id/v1/inboxes/$ADDR/wait?timeout=60" \
  -H "Authorization: Bearer $FANA_KEY" | jq -r .message.extracted.codes[0]

The same thing in JavaScript:

const key = process.env.FANA_KEY;
const headers = { authorization: `Bearer ${key}` };

const { address } = await fetch("https://fana.jstnlt.my.id/v1/inboxes", {
  method: "POST",
  headers: { ...headers, "content-type": "application/json" },
  body: "{}",
}).then((r) => r.json());

await signUpWithEmail(address); // whatever you are testing

// Blocks until it lands — no polling loop, no sleep().
const { message } = await fetch(
  `https://fana.jstnlt.my.id/v1/inboxes/${address}/wait?from=noreply&timeout=60`,
  { headers },
).then((r) => r.json());

const code = message.extracted.codes[0];

Authentication

Every /v1 request carries your key as a bearer token. Keys start with fk_, belong to an account, and inherit that account's plan — holding two keys does not buy two allowances. Only a hash is stored, so a key is shown once and a lost one is revoked rather than recovered.

Authorization: Bearer fk_...

Limits and quota

Counted per account, per calendar month in UTC. Every response carries what is left, so a client can back off before it runs out.

X-Plan: free
X-Quota-Limit: 1000
X-Quota-Remaining: 998

Over the per-minute ceiling answers 429 with Retry-After; over the monthly quota answers 429 naming the plan. A waiting call costs one request however long it waits, which makes /wait cheaper than polling as well as faster.

PlanRequests / monthPer minuteRetentionInboxes at once
Free1,000301 hour3

These are fana.jstnlt.my.id's numbers, read from this instance — a deployment you run yourself sets its own.

Inboxes

An inbox is an address this instance will accept mail for, held for as long as your plan keeps mail. Inboxes minted here are private: only your account's keys can read what arrives.

POST/v1/inboxes

Mint an address and hold it

Omit the domain and one of the served domains is picked at random, which spreads addresses across the instance instead of piling them onto the first one.

Body

domain
string
One of the served domains. Random when omitted.
private
boolean
Defaults to true. Pass false for a shareable inbox that behaves like the website's.

GET/v1/inboxes

List the inboxes you are holding

Includes how many messages are in each and how long the hold has left, plus the plan's concurrent-inbox limit.

GET/v1/inboxes/:address/messages

List messages, newest first

Query

limit
int
1–100, default 50.

GET/v1/inboxes/:address/wait

Block until a message matches

Mail already in the inbox matches immediately, so a message that arrived between triggering the signup and making the call is never missed. The oldest match after `since` wins — pass the previous message's receivedAt back to walk forward without skipping.

Query

from
string
Case-insensitive substring of the sender address.
subject
string
Case-insensitive substring of the subject.
since
ISO 8601
Only mail received after this timestamp.
timeout
int
Seconds to wait. Default 30, maximum 120.

DELETE/v1/inboxes/:address

Empty an inbox, keep the address

For reusing one address across test runs.

POST/v1/inboxes/:address/release

Give the address back

Drops the hold and deletes its mail. This is what frees a concurrent-inbox slot early.

Messages

Reading one message also returns the codes and links pulled out of it. Listings don't carry that — see Extraction below.

GET/v1/messages/:id

Read a message and mark it seen

DELETE/v1/messages/:id

Delete a message

Webhooks

Push instead of poll, for callers that cannot hold a connection open — a serverless function, a short CI step. Endpoints belong to the account, so rotating a key does not stop deliveries. Only mail your account owns is sent; a public inbox has nobody to notify.

GET/v1/webhooks

List your endpoints

POST/v1/webhooks

Register an endpoint

Body

url
stringrequired
https, and reachable from the internet.
label
string
For your own reference.

PATCH/v1/webhooks/:id

Pause, resume or rename

Resuming also clears the failure count, so a fixed endpoint gets a clean run.

Body

enabled
boolean
Pause or resume delivery.
label
string
Rename it.

POST/v1/webhooks/:id/test

Send a sample event now

Runs through the same signing and address checks as a real delivery and answers with what your endpoint said, so a pass means something. Not queued and not retried.

DELETE/v1/webhooks/:id

Remove an endpoint

GET/v1/webhooks/:id/deliveries

Recent attempts, with status codes and errors

Query

limit
int
1–100, default 20.

Account

What your key is, what plan it inherits, and what is left of it.

GET/v1/me

Key, plan, quota used and inboxes held

GET/v1/domains

Domains this instance accepts mail for

The free surface

What the website itself runs on. No key, no quota beyond a per-IP rate limit, and no privacy: every inbox here is readable by anyone who knows the address. Good for a quick look, wrong for anything you would automate.

POST/api/mailbox/random

Mint a random public address

GET/api/mailbox/:address/messages

List public messages for an address

GET/api/messages/:id

Read one public message

GET/api/domains

Domains this instance serves

GET/api/plans

Plans this instance offers

Verifying a webhook

Every delivery carries X-Fana-Signature: an HMAC-SHA256 of <timestamp>.<raw body> using the endpoint's signing secret. Check it before trusting the payload — anyone who learns your URL can post to it otherwise. The timestamp is signed too, so a captured request stops working once it is older than five minutes.

import { createHmac, timingSafeEqual } from "node:crypto";

// The raw body, before any JSON parsing — re-serializing changes the bytes.
export function verify(secret, rawBody, header) {
  const { t, v1 } = Object.fromEntries(
    header.split(",").map((p) => p.trim().split("=")),
  );
  if (Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;

  const expected = createHmac("sha256", secret)
    .update(`${t}.${rawBody}`)
    .digest("hex");
  return timingSafeEqual(Buffer.from(expected), Buffer.from(v1));
}

Answer 2xx to accept a delivery. Anything else is retried with a growing delay — seconds, then minutes, then hours — and given up on after six attempts. An endpoint whose deliveries keep failing is paused, and you turn it back on in the dashboard once it is fixed.

Codes and links

Reading a single message also returns what the mail was probably sent for. codes is ordered by how close each candidate sits to a word naming it, so codes[0] is the one to type. Numbers inside tracking links, prices, phone numbers and copyright years are excluded, and when nothing in the mail is called a code the list is empty rather than a guess.

{
  "message": {
    "subject": "Confirm your email",
    "extracted": {
      "codes": ["483920"],
      "links": ["https://example.com/verify?t=abc123"]
    }
  }
}

Listings do not carry it — extraction is for the message you opened, and running it over a hundred rows nobody read would be pure work.

Errors

Errors are JSON with an error string. A private message belonging to another account answers 404 rather than 403, so nothing is confirmed about an address you do not own.

400Malformed input — an unserved domain, a bad timestamp.
401Missing or unknown key.
404No such message or inbox, or not yours.
429Over the per-minute burst, the monthly quota, or the concurrent-inbox limit.
503Could not allocate an address; retry.

Domains

Mail is accepted for 2 domains right now. Any of them can be asked for by name when minting an inbox; omit the domain and one is picked at random.

  • tmp.sinikoding.dev
  • tmp.pgl.my.id

Community domains are self-service, so the set changes without notice and can grow well past what is worth printing. Read it at run time rather than copying from here.

curl https://fana.jstnlt.my.id/api/domains