Make.com transcription: the webhook-first pipeline

A transcription job takes minutes to finish. A Make scenario is billed by the module execution. Those two facts pull against each other, and resolving that tension is what this tutorial is actually about.


The obvious design — submit a file, then sleep, check, sleep, check until it is ready — costs nothing on a runner you host yourself. On Make it is not free. Every sleep and every status check is an operation, and a four-minute job can burn thirty of them doing nothing but asking "are we there yet?".


So we will build it the other way round. Inwista tells Make the moment the work is finished, and the scenario that catches it is three modules long.


By the end you will have:


  1. A receiver scenario that wakes up when a transcript is ready
  2. A submitter scenario that hands files to Inwista and then stops
  3. Signature verification, so the receiver only trusts real events
  4. Optional extras — translation fan-out, deduplication, and a retention sweep


Everything runs on the public Inwista API v1 through Make's standard HTTP module. No custom app, no code.

Before you start

You need three things:


  • A Make account — the free plan is fine for building this, though its operations ceiling is low for production
  • An Inwista API key — create one under My workspace → API keys. It starts with inw_live_
  • Media the API can reach — the API takes a public https URL, so files in Dropbox, Google Drive, S3 or your CMS need a shareable or signed link


You do not need anywhere to host a webhook endpoint. Make hands you the URL.


A note on cost before you build something that runs unattended: transcription is billed at 4 credits per started minute of media, charged when the job is accepted. Failed jobs are refunded automatically. Point the scenario at a couple of short test files before you point it at your archive.

Why the shape matters here

Both designs work. They just cost very different amounts, and on Make that difference compounds every month.


A polling scenario for a job that finishes in about four minutes, checking every twenty seconds, looks roughly like this: one submit, then twelve rounds of sleep plus status check plus router, then fetch and deliver. Call it 39 operations per file.


The webhook version splits into two scenarios. The submitter is a trigger and one HTTP call. The receiver is a webhook, a parse, a fetch and a delivery. Call it 6 operations per file.


At 200 recordings a month that is 7,800 operations against 1,200 — the difference between a plan tier and a rounding error. It also removes the two failure modes that bite polling scenarios in production: an execution that runs long enough to hit Make's 40-minute ceiling, and a stuck job that quietly loops all night.


Build the receiver first. It is the part that has to be right.

Step 1: Create the receiver

New scenario. Add a module, choose Webhooks → Custom webhook, click Add, name it something like inwista-transcripts, and copy the URL Make gives you.


Before you leave that dialog, open the webhook's advanced settings and turn on JSON pass-through.


This is the one setting people miss, and it is worth understanding rather than just copying. Inwista signs each delivery with an HMAC computed over the raw bytes of the request body. If Make parses the JSON for you, those exact bytes are gone — key order and whitespace included — and you can no longer reproduce the signature. Pass-through hands you the body untouched as a single string, which costs you one extra module later to parse it, and buys you the ability to verify anything at all.


Now paste that URL into Inwista under My workspace → Integrations, subscribe it to transcript.completed, and copy the signing secret it shows you.

Step 2: Verify the signature

Right-click the connection leaving the webhook and add a filter. Make's sha256() function computes an HMAC when you give it a key, so the whole check is one expression — no crypto module, no code.


Condition, Text: Equal to:

{{1.headers.x-inwista-signature}}

sha256={{sha256(1.data; "hex"; "your_signing_secret")}}


Two details that will cost you an afternoon otherwise. Make exposes incoming header names in lowercase, so it is x-inwista-signature, not the capitalised form you will see in our docs. And the header value carries the sha256= prefix, so either prepend it as above or strip it before comparing.


Anything that fails the filter simply stops. An unsigned request never reaches the modules that do work.


Add a JSON → Parse JSON module after the filter, pointed at 1.data. From here the payload behaves like any other Make bundle.

Step 3: Fetch and deliver

The event already contains everything you need:

{
  "event": "transcript.completed",
  "id": "evt_...",
  "timestamp": 1786902819,
  "workspaceId": "...",
  "project": {
    "id": "...",
    "name": "board-meeting-august.mp4",
    "language": "en",
    "durationSeconds": 3184
  },
  "files": [
    { "format": "srt", "name": "board-meeting-august.srt", "url": "https://...", "expiresAt": 1786989219 }
  ]
}


Add HTTP → Get a file and map the URL to {{2.files[1].url}}.


That [1] is not a typo. Make arrays are one-indexed, and reaching for [0] out of habit returns an empty value rather than an error — which then travels quietly downstream and shows up as a zero-byte file in Drive three days later.


Those URLs are signed and valid for 24 hours. Fetch the file; do not store the link.


Then attach whatever "done" means for your team — Google Drive, Dropbox, S3, Slack, an HTTP call into your CMS, a row in Airtable or Notion.


Three modules and a filter. That is the entire receiver, and it handles every completed job in the workspace — including recordings your colleagues upload through the dashboard by hand, which no polling scenario would ever have known about.

Step 4: The submitter

Second scenario. Start with whatever event means "there is something new": a Google Drive or Dropbox watch module, a Custom webhook from your own CMS, or a scheduled poll over a database. For testing, just run it manually.


Its only job is to produce a publicly reachable URL. Add HTTP → Make a request:


  • URL — https://api.inwista.ai/v1/transcriptions
  • Method — POST
  • Headers — Authorization: Bearer inw_live_your_key_here
  • Headers — Idempotency-Key: {{md5(1.fileUrl)}}
  • Body type — Raw, content type JSON


{
  "source_url": "{{1.fileUrl}}",
  "language": "en",
  "diarization": true,
  "metadata": { "source": "make", "scenario": "{{1.folderName}}" }
}


Three fields worth understanding:


  • language is the language spoken in the file, not the language you want out. Transcribing in the source language is what produces accurate timestamps and clean text; translation happens afterwards, on the finished transcript. If your pipeline handles several languages, map the trigger's folder to this field.


  • diarization turns on speaker labels. Leave it off for single-speaker content — it adds processing time you do not need.


  • metadata is yours. Up to 1 KB of anything, echoed back verbatim on every read and on the webhook — which is how the receiver knows which scenario, course or case a file belongs to without looking anything up.


Note what the idempotency key is derived from. Deriving it from the run protects you against a retry of that one module. Deriving it from the file, as above, also protects you against the same recording being submitted twice by two different runs — the far more common way people accidentally pay twice.


The response comes back immediately with status: "processing" and an id. The scenario ends there. That is the design working, not the design failing.

Fanning out translations with an Iterator

One recording into six languages is where Make's array handling earns its keep.


In the receiver, after the transcript arrives, add a Tools → Set variable holding your target list, then an Iterator over it, then a single HTTP module inside the loop:

POST https://api.inwista.ai/v1/transcriptions/{{2.project.id}}/translate

{ "target_language": "{{4.value}}" }


Each call returns 202 Accepted. Translation runs on the finished transcript, so the timings are already correct and only the text changes. Six languages costs six operations, and the finished files land through the same webhook you already built.


When you fetch them, ask explicitly:

GET /v1/transcriptions/{id}/captions?format=srt&language=no


The language parameter is strict. Requesting a language with no completed translation returns an explicit error rather than quietly handing back the source language — which is precisely what you want in a pipeline nobody is watching.

Broadcast-quality subtitles

Raw transcription is verbatim. Subtitles are a craft: line lengths, reading speed, where a sentence breaks across two cards.


One more HTTP module, POST /v1/transcriptions/{id}/enhance, runs the transcript through that treatment — condensing text, rebalancing line breaks, formatting dialogue, enforcing block durations:

{
  "settings": {
    "maxLinesPerBlock": "2",
    "maxCharactersPerLine": 42,
    "textCondensation": "smart",
    "speakerDialogueFormat": "hyphens",
    "gapBetweenBlocks": "broadcasting"
  }
}


Afterwards the caption endpoints serve the enhanced version automatically. Nothing downstream changes.

When something fails: Make's error routes

Right-click any module and choose Add error handler. Make gives you directives that a generic IF branch cannot express:


  • Break — parks the execution in Incomplete Executions so you can fix the cause and retry that exact bundle. Put this on the submit module.
  • Ignore — log it and move on. Right for a delivery step that is nice-to-have.
  • Resume — substitute a fallback value and carry on.
  • Rollback — undo committed work in transactional modules.


Every Inwista failure returns the same envelope:

{ "error": { "code": "insufficient_credits", "message": "..." } }


Branch on code, never on the message text. Codes are additive within v1 and never renamed; messages may be reworded at any time.


On our side, webhook delivery is retried three times, and an endpoint that keeps failing is disabled automatically with the reason shown in your integration settings — so a broken receiver surfaces as a status you can see rather than events that quietly vanish.

Not transcribing the same file twice

Watched-folder triggers re-fire. Files get renamed. Someone re-uploads.


Make has a native answer: a Data store. Create one keyed on the source file's ID, then in the submitter add Data store → Get a record before the HTTP call, filter on the record not existing, and Add a record after a successful submission.


Two operations to avoid paying for a duplicate transcription. The idempotency key covers retries of one module; the data store covers everything else.

Handling sensitive recordings

If your pipeline processes material you would rather we did not keep — patient interviews, legal recordings, internal all-hands — add one field to the submission:

{
  "source_url": "{{1.fileUrl}}",
  "language": "en",
  "retention": "none"
}


With retention: "none", the source media is deleted as soon as transcription completes. Transcript, captions, translations and later enhancements all keep working — only the audio and video are gone. There is also store_media: false, which keeps the file for processing but builds no playback copies.


For the far end of the lifecycle, DELETE /v1/transcriptions/{id} erases everything for a job in one call. A scheduled scenario that reads job IDs older than your policy window out of the same data store and deletes them is four modules — and it turns your retention policy into something you can demonstrate rather than describe.

When polling is still the right answer

Three cases genuinely call for it: you cannot expose a webhook, you need the transcript inside the same execution to answer a synchronous request, or you are running a one-off backfill where operation count does not matter.


If so, add a Tools → Sleep module and a status check against GET /v1/transcriptions/{id}, and respect two ceilings: Sleep caps at 300 seconds per module, and a scenario execution caps at 40 minutes. Poll at 20 to 30 second intervals rather than five — the response carries a progress number reflecting the pipeline's real position, so a slower loop still gives you something honest to display.


If you would rather see that pattern built out properly, our n8n version of this tutorial uses polling throughout, because on a self-hosted runner the loop is free.

Things that will bite you eventually

Arrays are one-indexed. files[1] is the first file. files[0] returns empty rather than failing.


Pass-through and parsing are a trade. You cannot verify a signature against a body Make has already parsed. Pass-through plus a Parse JSON module is the only correct order.


Rate limits. 300 reads and 60 writes per minute per key. Generous for normal use, easy to hit if you fan an archive out across parallel scenario runs. Batch backfills.


Source URLs must be reachable. The API probes the media before accepting the job — that is how duration and cost are known upfront. A Drive link that requires a login returns unreadable_source, and nothing is charged. Use direct or signed URLs.


Everything counts. Sleep modules, routers, iterator cycles and filters that pass all consume operations. When a scenario feels expensive, count modules before you blame the API.

The finished receiver

Here is the blueprint skeleton. Import it, then attach your own webhook and delivery module — and use Make's connection store rather than pasting a key into a module.

{
  "name": "Inwista — transcript receiver",
  "flow": [
    {
      "id": 1,
      "module": "gateway:CustomWebHook",
      "version": 1,
      "parameters": { "hook": 0, "maxResults": 1 },
      "mapper": {},
      "metadata": { "designer": { "x": 0, "y": 0 } }
    },
    {
      "id": 2,
      "module": "json:ParseJSON",
      "version": 1,
      "parameters": { "type": 0 },
      "mapper": { "json": "{{1.data}}" },
      "metadata": { "designer": { "x": 300, "y": 0 } }
    },
    {
      "id": 3,
      "module": "http:ActionGetFile",
      "version": 3,
      "parameters": {},
      "mapper": { "url": "{{2.files[1].url}}", "serializeUrl": false },
      "metadata": { "designer": { "x": 600, "y": 0 } }
    }
  ],
  "metadata": {
    "instant": true,
    "version": 1,
    "scenario": { "roundtrips": 1, "maxErrors": 3, "autoCommit": true },
    "designer": { "orphans": [] }
  }
}


Three modules. The polling equivalent was eleven, and it cost six times as much to run.

What this actually changes

The measure of an automation is not what it does while you are watching it. It is what it does at two in the morning on a Sunday, when a ninety-minute recording lands and nobody is awake.


A scenario that runs for four minutes per file, burning operations to ask a question it already knows the answer to, is a thing you end up checking on. A receiver that wakes up, verifies a signature, writes a file and goes back to sleep is a thing you forget exists — and forgetting it exists is the entire point.


At that stage subtitles stop being a task somebody owns. They become a property of every recording your organisation produces: searchable, accessible, compliant, and nobody had a meeting about it.


Ready to build it? Create an API key — the free tier is enough to run this end to end. The full endpoint reference lives in the API documentation.