WordPress webhook endpoint: receive Sume video callbacks

Register a REST route with register_rest_route, verify the signature on the raw body in PHP, and answer 2xx so Sume stops retrying.

5 min readSume
All posts

A WordPress webhook endpoint is a REST route: call register_rest_route() inside an rest_api_init callback, set 'methods' => 'POST', and give it a permission_callback. A webhook sender isn't a logged-in user, so the route is public and __return_true is the documented permission callback for that; the security comes from checking the signature yourself. For Sume, that means an HMAC-SHA256 check on the raw body.

WordPress facts come from the REST API handbook's Adding Custom Endpoints and the reference pages for get_body(), get_header() and WP_REST_Response, plus the PHP manual for hash_hmac and hash_equals, all read 2026-09-29. Sume facts come from Webhooks. Sume has no WordPress plugin, so this is plain PHP.

How do I register a public webhook route in WordPress?

Register the route on rest_api_init, with a namespace, a route and an options array. The handbook says that as of WordPress 5.5 a route without a permission_callback triggers a _doing_it_wrong notice, and that intended-public routes should use __return_true. The route below answers at /wp-json/acme/v1/sume.

add_action( 'rest_api_init', function () {
  register_rest_route( 'acme/v1', '/sume', array(
    'methods'             => 'POST',
    'callback'            => 'acme_sume_webhook',
    'permission_callback' => '__return_true',
  ) );
} );

function acme_sume_webhook( WP_REST_Request $request ) {
  $raw = $request->get_body(); // the body exactly as it arrived
  $ok  = acme_verify_sume(
    $raw,
    (string) $request->get_header( 'x-sume-webhook-timestamp' ),
    (string) $request->get_header( 'x-sume-webhook-signature' ),
    (string) getenv( 'SUME_COM_WEBHOOK_SIGNING_SECRET' )
  );
  if ( ! $ok ) {
    return new WP_REST_Response( array( 'error' => 'bad signature' ), 401 );
  }
  $event = json_decode( $raw, true );
  // Store $event durably here, keyed by $event['job_id'], then answer.
  return new WP_REST_Response( null, 204 );
}

How do I read the raw body and the signature header?

WP_REST_Request::get_body() returns the request body content as a string of binary data, and get_header( $key ) returns the header value or null, with the name canonicalized to lowercase. Verify the string from get_body(). Sume signs the raw JSON body, so a value you decoded and re-encoded can differ in key order or spacing and won't verify.

From WordPress's get_body() and get_header() references and Sume's Webhooks page, read 2026-09-29.
PieceWhere it comes fromUsed for
Raw body$request->get_body()The bytes Sume signed
x-sume-webhook-timestamp$request->get_header()Replay window; part of the signed string
x-sume-webhook-signature$request->get_header()sume-v1=<hex>, possibly several comma-separated entries
Signing secretDashboard Webhooks tab, kept outside the themeThe HMAC key

How do I verify the Sume signature in PHP?

Sume signs HMAC-SHA256 over <timestamp>.<raw_body> and sends sume-v1=<hex_signature>. During a secret rotation the header carries one entry per live secret, comma-separated, so accept the delivery when any entry matches. hash_hmac() returns lowercase hex by default. Compare with hash_equals(), known string first and the user-supplied string second, because the manual says a plain === leaks timing. Refuse an empty secret: an empty HMAC key would let a forged signature verify.

function acme_verify_sume( $raw, $ts, $header, $secret ) {
  if ( '' === $secret || ! is_numeric( $ts ) ) {
    return false;
  }
  if ( abs( time() - (int) $ts ) > 300 ) { // five-minute replay window
    return false;
  }
  $expected = 'sume-v1=' . hash_hmac( 'sha256', $ts . '.' . $raw, $secret );
  $matched  = false;
  foreach ( explode( ',', $header ) as $entry ) {
    if ( hash_equals( $expected, trim( $entry ) ) ) {
      $matched = true; // keep looping: compare every entry
    }
  }
  return $matched;
}

What should the route answer, and what does Sume do next?

  • Answer any 2xx after you've stored the event. Sume retries network errors and non-2xx responses, up to 10 attempts in total, about 30 seconds apart by default, with 10 seconds allowed per attempt.
  • Do slow work after the response, or in a scheduled task. A slow endpoint burns the 10-second budget and gets retried.
  • Dedupe on job_id: retries repeat it. Use it as the idempotency key on your side.
  • The webhook URL must be public HTTPS. Localhost and private-network URLs are rejected, so a local WordPress needs a public HTTPS tunnel while you test.
  • Sume sends terminal events only: job.completed, job.failed and job.canceled. Keep polling the job's status URL for deliveries that never arrive.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume