Integrations
Bulk and batching
Run thousands of Instagram links through the API without tripping a rate limit or paying for the same Reel twice.
One link at a time is the whole API. Ten thousand links is the same call, ten thousand times, and the only new problems are pace, failure and money.
There are three ways to run a large job. Pick by where your links already live.
| Where your links are | Use | Ceiling |
|---|---|---|
| A CSV or Excel file | The bulk page | 5,000 rows, tab stays open |
| A Google Sheet | The Apps Script runner | No practical limit, runs on Google |
| A database or a queue | This page | No practical limit |
Pace
A key allows six hundred calls an hour. That is the only limit that matters, and it is per key rather than per address.
Six hundred an hour is one every six seconds. A single loop that waits for each transcript before starting the next one lands somewhere near that on its own, because a transcript takes a few seconds to come back. Push harder and you will meet RATE_LIMITED.
Four or five at once is the sweet spot. It keeps the pipe full without queueing behind your own limit.
async function inBatches<T, R>(
items: T[],
size: number,
worker: (item: T) => Promise<R>,
): Promise<R[]> {
const out: R[] = [];
for (let at = 0; at < items.length; at += size) {
const slice = items.slice(at, at + size);
out.push(...(await Promise.all(slice.map(worker))));
}
return out;
}
Failure
Two codes turn up in every large job. Write for both before you start, because meeting them at row 4,000 with no handling is a lost afternoon.
PRIVATE is permanent. The post needs a login and will still need one next week. Record it and move on. Retrying a private post spends nothing, because credits come off after the words exist, but it does spend one of your six hundred calls an hour.
RATE_LIMITED is temporary and tells you how long. Read retry-after, sleep for that many seconds, then carry on from the same item.
type Transcript = { ok: true; transcript: string; durationSec?: number; language?: string };
type Failure = { ok: false; code: string; message: string };
async function transcribe(url: string): Promise<Transcript | Failure> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch("https://instagramtotranscript.com/v1/transcripts", {
method: "POST",
headers: {
authorization: `Bearer ${process.env.ITT_API_KEY}`,
"content-type": "application/json",
},
body: JSON.stringify({ url }),
});
if (response.status === 429) {
const wait = Number(response.headers.get("retry-after") ?? 60);
await new Promise((done) => setTimeout(done, wait * 1000));
continue;
}
const body = (await response.json()) as Transcript | Failure;
// A private post, a dead link or an unsupported address will answer the
// same way however many times it is asked. Only a 5xx is worth another go.
if (body.ok || response.status < 500) return body;
await new Promise((done) => setTimeout(done, 2 ** attempt * 1000));
}
return { ok: false, code: "TRANSCRIBE_FAILED", message: "Gave up after four attempts." };
}
Never pay twice
Write each transcript down before you ask for the next one, and skip anything already written. This is the difference between a job you can stop and a job you have to finish.
Keyed by the shortcode rather than the full address, because the same Reel reaches you as five different URLs. A share link, a profile-prefixed link, one with tracking parameters on the end, and two with different capitalisation of www are all one post and one credit.
/** The part of an Instagram address that identifies the post. */
function shortcode(url: string): string | null {
const match = /instagram\.com\/(?:[^/]+\/)?(?:reel|reels|p|tv)\/([A-Za-z0-9_-]+)/i.exec(url);
return match ? match[1] : null;
}
Deduplicate on that before the first call. On a list scraped from anywhere, expect it to remove a few percent.
Money
One credit is one minute of audio, rounded up. Nothing else is charged, and a failure is free.
Reels run short. The average across this site is a little under a minute, which means most rows cost one credit and the estimate you want is close to your row count. Multiply by the pack rate to get the bill.
GET /v1/transcripts reads your balance without spending anything, so check it before a large run rather than discovering the floor half way down.
curl https://instagramtotranscript.com/v1/transcripts \
-H "Authorization: Bearer $ITT_API_KEY"
{
"ok": true,
"key": { "id": "key_...", "name": "batch job" },
"credits": { "remaining": 2418 },
"limits": { "requestsPerHour": 600, "maxDurationSec": 600 }
}
Every transcript answer carries the same two numbers in its headers, so a long job can watch the balance fall without a second call.
x-credits-spent: 2
x-credits-remaining: 2416
Stop the job when x-credits-remaining gets near zero. Running to empty is not harmful, and nothing goes negative, but a column of INSUFFICIENT_CREDITS is a pass you have to run again.
A run that can be stopped
Put together, a large job is a small loop over rows that have no transcript yet.
const links = await db.reels.findMany({ where: { transcript: null }, take: 5000 });
const seen = new Set<string>();
for (const row of links) {
const code = shortcode(row.url);
if (!code || seen.has(code)) continue;
seen.add(code);
const result = await transcribe(row.url);
await db.reels.update({
where: { id: row.id },
data: result.ok
? { transcript: result.transcript, seconds: result.durationSec, status: "done" }
: { status: result.code },
});
if (!result.ok && result.code === "INSUFFICIENT_CREDITS") break;
}
Kill it at any point and start it again. It picks up where it stopped, because the filter on the first line is the only state it keeps.