Latch stores your two-factor accounts and hands the live TOTP codes back over HTTP. One header, five endpoints, JSON in and JSON out. Every example on this page runs as-is once your key is in place.
Three steps, about a minute.
curl -H "X-API-Key: $LATCH_API_KEY" https://latch.cc/api/codes
{
"codes": [
{
"id": 1,
"issuer": "GitHub",
"label": "ci-bot@example.com",
"code": "130604",
"seconds_remaining": 22,
"digits": 6,
"period": 30,
"algorithm": "SHA1"
}
]
}
seconds_remaining is how long this code stays valid. When it is
low, wait the window out instead of submitting a code that expires mid-request:
#!/bin/sh # print a code with a full window ahead of it while :; do json=$(curl -sf -H "X-API-Key: $LATCH_API_KEY" https://latch.cc/api/codes/1) left=$(printf '%s' "$json" | jq -r .seconds_remaining) [ "$left" -ge 5 ] && printf '%s\n' "$json" | jq -r .code && break sleep "$left" done
Send your key in the X-API-Key header on every request. No OAuth
dance, no token exchange, no expiry.
X-API-Key: latch_your_key_here
https://latch.cc — HTTPS only.latch_, so they are recognisable in a
log or a secret scanner.401.404, identical to
an id that does not exist.Live code for every account in your vault. An empty vault returns an empty list, not an error.
curl -H "X-API-Key: $LATCH_API_KEY" https://latch.cc/api/codes
Live code for one account — so a job only ever sees the single code it needs.
curl -H "X-API-Key: $LATCH_API_KEY" https://latch.cc/api/codes/1
{
"id": 1,
"issuer": "GitHub",
"label": "ci-bot@example.com",
"code": "130604",
"seconds_remaining": 22,
"digits": 6,
"period": 30,
"algorithm": "SHA1"
}
Your accounts, metadata only — no codes, no secrets. This is how
you discover the id values the other endpoints take.
curl -H "X-API-Key: $LATCH_API_KEY" https://latch.cc/api/accounts
{
"accounts": [
{"id": 1, "issuer": "GitHub", "label": "ci-bot@example.com",
"digits": 6, "period": 30, "algorithm": "SHA1"}
]
}
Store a new 2FA secret. Send either the raw base32
secret a site reveals under “can't scan the QR code?”,
or the whole otpauth:// URI that the QR encodes.
| Field | Type | Notes |
|---|---|---|
secret | string | Base32; spaces are fine. Required unless uri is given. |
uri | string | otpauth://totp/… — fills in every other field for you. |
issuer | string | Service name, e.g. GitHub. Optional. |
label | string | Which account at that service. Optional. |
digits | int | 6–8. Default 6. |
period | int | Seconds per code. Default 30. |
algorithm | string | SHA1 (default), SHA256, SHA512. |
curl -X POST https://latch.cc/api/accounts \
-H "X-API-Key: $LATCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"secret":"JBSWY3DPEHPK3PXP","issuer":"GitHub","label":"ci-bot@example.com"}'
# same thing, straight from the otpauth:// URI the setup QR encodes
curl -X POST https://latch.cc/api/accounts \
-H "X-API-Key: $LATCH_API_KEY" \
-H "Content-Type: application/json" \
-d '{"uri":"otpauth://totp/GitHub:ci-bot@example.com?secret=JBSWY3DPEHPK3PXP&issuer=GitHub"}'
201 with the
created account. Metadata only — the secret is never echoed back.
Permanently remove an account and its stored secret. Make sure you have another way into that service first.
curl -X DELETE -H "X-API-Key: $LATCH_API_KEY" https://latch.cc/api/accounts/1
Returns
{"ok": true}, or 404 if that id is not yours.
Every error is JSON with a single error string at the matching
HTTP status. Nothing under /api/ ever returns an HTML page.
{"error": "invalid or missing API key"}
| Status | Meaning |
|---|---|
200 | Fine. |
201 | Account created. |
400 | Body missing, or the secret is not valid base32 / not a TOTP otpauth:// URI. |
401 | Missing, malformed or unknown X-API-Key. |
403 | Free-plan cap reached (50 entries). |
404 | No such id in your vault — including ids that exist but belong to somebody else. |
405 | Wrong method for that path; the body lists what is allowed. |
403.period
seconds, so polling faster gains nothing.import os, requests
r = requests.get("https://latch.cc/api/codes",
headers={"X-API-Key": os.environ["LATCH_API_KEY"]},
timeout=15)
r.raise_for_status()
for c in r.json()["codes"]:
print(c["issuer"], c["code"], c["seconds_remaining"], "s left")
const res = await fetch("https://latch.cc/api/codes/1", {
headers: { "X-API-Key": process.env.LATCH_API_KEY },
});
if (!res.ok) throw new Error(`latch ${res.status}: ${await res.text()}`);
const { code, seconds_remaining } = await res.json();
if (seconds_remaining < 5) await new Promise(r => setTimeout(r, seconds_remaining * 1000));
console.log(code);
- name: Fetch a 2FA code
id: totp
run: |
code=$(curl -sf -H "X-API-Key: ${{ secrets.LATCH_API_KEY }}" \
https://latch.cc/api/codes/1 | jq -r .code)
echo "::add-mask::$code"
echo "code=$code" >> "$GITHUB_OUTPUT"
Codes are generated by a TOTP implementation written against RFC 6238 using only the Python standard library and checked against the RFC's own test vectors, so a Latch code is the same code your phone's authenticator would show.