Apps Script doPost and the Sume webhook signature: what you can verify
An Apps Script web app doPost documents the body but no headers, and Sume signs in headers. Treat the callback as a hint and re-read the job with your key.

You cannot verify a Sume webhook signature from an Apps Script doPost(e) as documented, because Sume sends the signature in request headers and Google's web app guide lists only the body and query fields on the event object. The safe pattern is to treat the callback as a hint, take the job id from it, and read the real state from the Sume API with your own key.
Everything below about Apps Script comes from Google's web apps guide, read on 2026-10-02. If Google adds header access later, switch to a normal signature check.
What does doPost(e) give you?
The guide documents e.queryString, e.parameter, e.parameters, e.pathInfo, e.contextPath, e.contentLength and e.postData, with the raw text in e.postData.contents. It says the function must return an HTML Service or Content Service output. The guide does not document a headers field.
What does Sume put in the headers?
Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends x-sume-webhook-timestamp and x-sume-webhook-signature: sume-v1=<hex>; every delivery also carries x-sume-webhook-secret-fingerprint. The verifying webhooks page covers the scheme. All three values are headers, so a receiver that cannot read headers cannot run the check.
A signature is also not the only way to know a callback is real. Job webhooks name a job_id, and GET /v1/jobs/{id}/status answers with the true state for a key you hold.
How do I handle the callback safely anyway?
Never write the callback's own result into your sheet. Use it only to learn which job to look at, then fetch status yourself. A forged callback can then cause one extra read, nothing more. The code stores the key in Script Properties, ignores anything without a job_ id, and writes only what the API returns.
function doPost(e) {
const body = JSON.parse(e.postData.contents || '{}');
const jobId = String(body.job_id || '');
if (!/^job_[A-Za-z0-9_]+$/.test(jobId)) return ContentService.createTextOutput('ignored');
const key = PropertiesService.getScriptProperties().getProperty('SUME_API_KEY');
const res = UrlFetchApp.fetch('https://api.sume.com/v1/jobs/' + jobId + '/status', {
headers: { Authorization: 'Bearer ' + key },
muteHttpExceptions: true,
});
const status = JSON.parse(res.getContentText());
const sheet = SpreadsheetApp.getActiveSheet();
const ids = sheet.getRange(1, 2, sheet.getLastRow(), 1).getValues().flat();
const row = ids.indexOf(jobId) + 1;
if (row > 0 && status.terminal) sheet.getRange(row, 3).setValue(status.sume_status);
return ContentService.createTextOutput('ok');
}Why not just skip the signature?
A signature proves a delivery was signed for your workspace: Sume derives the secret per workspace, so a valid signature means the call came from Sume for you. Without it, anyone who learns your web app URL can post a body that names a job id. That is why the code above never trusts the body for anything except a lookup key.
The job id regex matters too. Spreadsheet code that builds a URL from unchecked input is an easy place to introduce a bug, so reject anything that does not look like a Sume job id before you build the request. Also remember that a callback only arrives for jobs submitted with a webhook_url; rows submitted with mode: "async" alone are found by the polling trigger as in Apps Script UrlFetch quotas and a Sume sheet run.
How do I test this receiver before real jobs?
Use Sume's Send test, which is a control on the dashboard Webhooks page or POST /v1/webhooks/test-deliveries. It posts a signed dummy webhook.test body to a URL you type and never replays a real job. That body has no job_id, so the receiver above should answer ignored and write nothing, which is exactly the behavior you want to confirm.
Then submit one cheap image with your web app URL as webhook_url, and read the job's events for the webhook.delivery entry. If it shows a failed attempt, check that the deployment is reachable without a Google sign-in, because Sume calls it as an anonymous public client.
What are the limits of this approach?
Sume retries a non-2xx or timed-out delivery up to 10 attempts total with a 10-second timeout per attempt, so return quickly. The URL must also be public HTTPS. Keep polling as a backup, as the webhook docs advise.
If you need a verified receiver, use a platform that exposes headers, such as the Hono or Express receivers in Hono webhook: verify the signature on the raw body.
| Option | Verifies signature | Cost |
|---|---|---|
| doPost as a hint, then GET status | No, but state comes from the API | One UrlFetch per callback |
| Relay with a header-aware service, then call the web app | Yes, at the relay | Extra hop to run |
| Poll only, no webhook | Not needed | UrlFetch calls against the 20,000 daily quota |
Sources
Related posts
More in Integrations
- Apps Script 90-minute daily trigger runtime: polling Sume jobs
Consumer Google accounts get 90 minutes of trigger runtime a day. Poll Sume jobs from one time-driven trigger that scans the sheet, not one trigger per row.
- Apps Script UrlFetch 20,000/day and 6 min: size a Sume sheet run
Apps Script allows 20,000 UrlFetch calls a day and 6 minutes per run on a consumer account. How to size a Sume sheet batch inside both limits.
- Make an avatar video from an MCP agent: tools, dry run, spend cap
The hosted MCP server of Sume exposes avatars_list, avatar-videos_create and jobs_wait. How dry_run and max_spend_usd gate a paid call, and the scope you need.
- Poll a Sume job from a CI step with curl and jq: exit codes
A 23-line bash script that polls /v1/jobs/{id}/status and exits 0, 1, 2 or 3, so a CI step can tell done, failed, still running and a bad request apart.
Written by Sume