DEV Community

Victor Cavero Gracia
Victor Cavero Gracia

Posted on

Webhook-first API design: no polling, one signed callback, and an MCP server that is not a wrapper

Most video APIs I have integrated make you poll. You POST a job, get an id, and then write a loop that asks "are we there yet" every few seconds until something comes back or your timeout guess runs out. That loop is where the bugs live: doubled work on retries, jobs that hang forever on silence, and a scheduler that quietly burns rate limit while nothing happens.

While building OpenShorts, an open source tool that cuts long videos into vertical clips, we made three API decisions that removed most of that pain. Here they are, with the reasoning and the code.

1. One request in, one signed webhook out

The whole automation loop is two HTTP messages. Start a job:

curl -X POST https://api.openshorts.app/api/process \
  -H "Authorization: Bearer osk_..." \
  -H "Content-Type: application/json" \
  -d '{"url": "https://youtube.com/watch?v=...",
       "acknowledged": true,
       "webhook_url": "https://your-server.com/hooks/openshorts",
       "webhook_secret": "your-shared-secret"}'
Enter fullscreen mode Exit fullscreen mode

You get a job id back immediately. Minutes later, when the clips are cut, subtitled and archived, you receive exactly one POST on your webhook with the clip titles and durable download links.

The part that matters more than it sounds: a failed job fires the webhook too. If failure is silent, every consumer has to reinvent a timeout, and every one of them picks a different number. Making failure a delivered event instead of an absence of events is what lets a pipeline be stateless.

2. Sign the callback, and compare in constant time

If you pass a webhook_secret, the delivery carries an X-OpenShorts-Signature header shaped like sha256=<hex>, which is the HMAC-SHA256 of the raw request body.

import hmac, hashlib

expected = "sha256=" + hmac.new(secret, raw_body, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers["X-OpenShorts-Signature"]):
    return 401
Enter fullscreen mode Exit fullscreen mode

Two details that get missed often enough to be worth repeating:

  • Hash the raw body, not the parsed and re-serialised JSON. Key order and whitespace will not survive a round trip through your framework.
  • Use compare_digest, not ==. A plain string comparison returns early on the first differing byte, which leaks how much of the prefix you guessed right.

3. Treat MCP as a first class surface, not a wrapper

We shipped an MCP server at mcp.openshorts.app/mcp so an agent can drive the pipeline directly. Connecting is one line:

claude mcp add --transport http openshorts https://mcp.openshorts.app/mcp \
  --header "Authorization: Bearer osk_..."
Enter fullscreen mode Exit fullscreen mode

Any client that speaks Streamable HTTP works the same way. The server describes itself over the protocol, tool schemas included, so there is nothing else to configure.

Six tools cover the pipeline: process_video, get_job_status, list_clips, get_quota, add_subtitles and publish_clip.

get_quota is the one I would argue hardest for. An agent that cannot see its own budget will happily start a job it cannot finish, and you find out from a failed webhook twenty minutes later. Exposing the remaining balance as a tool lets the model check before it spends, which turns a runtime failure into a planning decision.

The other thing we refused to do was build the MCP server as a wrapper around a convenient subset of the REST API. Each tool calls the same pipeline the web app uses, with the same account, minutes and job history. The moment the agent surface is a subset, you have two products to keep in sync, and the agent one always loses.

The meter is an API design decision

This is the part I did not expect to matter, and it turned out to matter most.

Most tools in this space meter agent calls separately, per source minute or per operation. That is defensible billing and terrible ergonomics. An agent loop is exploratory by nature: it checks status, lists clips, retries the one that came out badly. If every call has a price, the correct engineering response is to write fewer, larger, more brittle calls, which is exactly the opposite of what you want from an agent.

We made API calls draw from the same flat minute balance as the dashboard. No separate meter, no per-call pricing. On the self-hosted edition there is no meter at all, which is what makes an always on pipeline affordable to run.

Scheduling is whatever you already have

Because starting a job is one POST, you do not need a scheduler integration. A cron line, a GitHub Action, an n8n HTTP Request node, all of them work without a dedicated connector. There is no official n8n template because the two step shape above is the entire integration.

Run it yourself

The code is on GitHub at mutonby/openshorts, the core is MIT licensed, and the self-hosted edition serves the same MCP endpoint as the cloud.

If you are building anything with webhooks and agents, the summary is short: deliver failures as events, sign the body and compare it in constant time, expose the budget as a tool, and do not put a price on the calls your agent needs to think.

Top comments (0)