communication.webhook_url 400: HTTPS, public host, 2048 chars

A communication.webhook_url that is not public HTTPS, is over 2048 characters, or points at localhost or a private network returns 400 invalid_request.

5 min readSume
All posts

A run create returns 400 invalid_request when communication.webhook_url is not a public HTTPS URL of at most 2048 characters. Sume rejects localhost, private-network addresses and non-HTTPS URLs. This is why a webhook that works against a tunnel on your laptop can fail the moment you point the create at http://localhost:3000/hook. Use an HTTPS tunnel with a public hostname for local work, or poll.

The field rules

The same shape works on Action runs, Format runs and Agent Completions.

FieldRule
communication.webhook_urlPublic HTTPS URL, at most 2048 characters
communication.callback_urlAccepted alias for webhook_url. Send one or the other
communication.modeasync (default) or webhook. The URL arms delivery; mode is descriptive
Top-level webhook_url, callback_url, modefal-shaped aliases, normalized into communication.*. The same value on both layers is fine; values that disagree return 400 invalid_request

One surprise comes from that last row. Sending webhook_url at the top level and a different communication.webhook_url is a 400, not a silent pick, so send the URL once.

Validation happens twice

Sume validates the URL again at delivery time, not only when you submit. If the URL no longer passes that check, the delivery fails re-validation and the receipt's webhook_delivery ends in failed or exhausted. Sume also does not follow redirects. A 3xx from your endpoint is not a delivery, so a route that redirects from http to https, or adds a trailing slash with a 301, loses every attempt. Register the final URL exactly as your server answers it.

A pre-flight check

The helper below catches the common mistakes before the create call. It is a convenience, not a copy of Sume's validator: Sume's check is the authority, and a URL that passes here can still be refused. It prints the result for a good URL, an http URL and a localhost URL.

// Cheap pre-flight for communication.webhook_url. Sume re-validates at delivery time.
export function webhookUrlProblem(raw) {
  if (raw.length > 2048) return "longer than 2048 characters";
  let u;
  try { u = new URL(raw); } catch { return "not a URL"; }
  if (u.protocol !== "https:") return "must be HTTPS";
  const h = u.hostname;
  if (h === "localhost" || h.endsWith(".local") || /^(10\.|127\.|192\.168\.|169\.254\.)/.test(h)) {
    return "local or private host";
  }
  return null;
}

for (const url of ["https://hooks.example.com/sume", "http://hooks.example.com/sume", "https://localhost:3000/h"]) {
  console.log(url, "->", webhookUrlProblem(url));
}

When delivery is armed

  • A URL on the create arms delivery on both api.dev.sume.com and api.sume.com. Until the run reaches a terminal state, webhook_delivery.status is not_armed.
  • One signed POST is sent when the run completes or fails. A canceled or skipped run never delivers.
  • Each attempt has a 10 second timeout, and a run delivery has up to 10 attempts.
  • If delivery ends in failed or exhausted, read the receipt from result_url, fix the endpoint, and replay with POST …/webhook/redeliver.
  • Keep polling status_url as a backup. A webhook only saves you the loop; it is not the only way to the result.

Local development

For a quick local loop, skip the webhook. Poll status_url and read result_url, or run waitForRun in a script. When you do need to test the receiver, expose it through an HTTPS tunnel that gives a public hostname, and verify signatures with your real signing secret so the code you test is the code you ship. The signing secret is readable on the Webhooks tab of the dashboard or from GET /v1/webhooks/signing-secret with a key that has account:read, and your receiver should refuse to start when it is empty. See Run webhooks for the field table and the delivery rules.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume