Vercel Workflow createWebhook is token-only: verify Sume first
createWebhook trusts only the URL token. For a Sume callback, verify the sume-v1 signature in your own route, then resume a hook with resumeHook.

Use createHook() behind your own route, not createWebhook(), to resume a Vercel workflow from a Sume callback. The Workflow SDK docs say the token in a webhook URL is the only authorization performed, so anyone holding the URL can resume the run, while Sume's callback carries an HMAC signature your route can check first.
This post covers the choice and the route. It does not cover the workflow function itself beyond the calls the SDK docs show, so check them for your SDK version.
What is the difference between createWebhook and createHook?
Both pause a run until something arrives. Per the docs, hooks accept arbitrary serializable data and you resume them with resumeHook(), while webhooks receive standard HTTP Request objects at a generated webhook.url. The createWebhook reference adds the warning that the URL token is the only authorization, and recommends createHook() behind your own authorized route when you need more.
| createHook | createWebhook | |
|---|---|---|
| Input | Any serializable data | A standard HTTP Request |
| Entry point | Your route calls resumeHook(token, payload) | Generated webhook.url |
| Authorization | Whatever your route checks | The URL token only |
| Custom token | createHook({ token }) | Not supported; tokens are always generated |
Why is the token-only model a poor fit for a Sume callback?
The Sume docs describe the webhook_url target and the signature Sume sends, and do not describe attaching your own auth header. What it sends is x-sume-webhook-signature: sume-v1=<hex> over <timestamp>.<raw_body>, plus a timestamp header, as described in the verifying webhooks docs. A token-only endpoint ignores all of that, so a leaked URL lets someone resume your workflow with any payload, including a fake job.completed.
The Workflow docs call generated tokens hard to guess without knowing the run, and custom hook tokens easier to reconstruct because they are built from domain data. A custom sume-<job_id> token is therefore fine only because your route checks the signature first.
What does the verifying route look like?
Set webhook_url to this route when you submit the job, create a hook in the workflow with a token derived from the job id, and let the route resume it only after the signature passes. verifyWebhook is async and returns false on a bad delivery, and it needs the raw body, so call request.text() first. The secret comes from GET /v1/webhooks/signing-secret or the dashboard. The empty-secret guard stops a missing env var from becoming an accept-everything check.
import { verifyWebhook } from "@sume-com/sdk";
import { resumeHook } from "workflow/api";
export async function POST(request: Request) {
const secret = process.env.SUME_COM_WEBHOOK_SIGNING_SECRET ?? "";
if (!secret) return new Response("no secret", { status: 500 });
const body = await request.text();
const ok = await verifyWebhook({ body, headers: request.headers, secret });
if (!ok) return new Response("bad signature", { status: 401 });
const event = JSON.parse(body);
if (event.event?.startsWith("job.")) {
await resumeHook(`sume-${event.job_id}`, event);
}
return new Response(null, { status: 204 });
}Is there ever a reason to use createWebhook?
Yes, when the sender cannot be given a route you control and the payload is not security sensitive, or when the URL stays private between two systems you own. The createWebhook page also supports a manual response mode, respondWith: 'manual', for returning a custom Response, and the default answers 202 Accepted automatically.
For a paid generation callback, the cost of a spoofed completion is a workflow that moves on without a real result, then publishes or bills something. That is worth one route and a signature check. If you do use createWebhook, at least confirm the job with GET /v1/jobs/{id}/status before acting on it, as in the other receivers in this series.
What can still go wrong?
Order matters: the hook has to exist before the callback arrives, or resumeHook has nothing to resume, and the Workflow docs say registration is only committed when the workflow suspends. Create the hook right after the submit returns the job id, suspend on it, and keep a status poll (GET /v1/jobs/{id}/status) in the workflow as a backup, since Sume's own docs call a webhook a delivery optimization rather than the only recovery path.
Sume retries a refused delivery up to 10 times, so a 5xx from a route that cannot find the hook yet will be retried. Answer 204 for event types you do not handle; Sume's SDK docs advise that an unknown event should not become a retry storm. And dedupe on job_id, because Sume's Redeliver and retries can send the same terminal event more than once.
Sources
Related posts
More in Integrations
- Windmill webhook token in the URL: calling it from a Sume job
Windmill prefers a bearer header, but Sume's webhook_url is just a URL, so the token must ride in the query string. How to scope it and still trust the result.
- Storage by Zapier 32-character keys: remember a Sume job id
Storage by Zapier keys are limited to 32 characters and 500 keys, and idle keys vanish after 2 months. Key by your row id, store the Sume job id as the value.
- Zed MCP: which agents use your Sume server (Panel, ACP, terminal)
Zed's own Agent uses context_servers directly, external agents get them over ACP, and terminal CLIs read their own config. Where the Sume MCP entry goes.
- How to add an MCP server to ChatGPT with developer mode
Turn on ChatGPT developer mode, create an app for the server's URL, and sign in with OAuth. The steps, with Sume's hosted MCP server as the example.
Written by Sume