Steve API
Get Started

Webhooks

Testing, signature verification, and delivery semantics for Steve webhooks.

Steve has two webhook-related behaviors:

  1. A live webhook connectivity test endpoint: POST /api/v1/webhooks/test
  2. Production session-result delivery from convex/webhooks.ts

Connectivity test

The test endpoint validates your URL and performs a direct POST with:

{
  "type": "webhook.test",
  "timestamp": "2026-03-22T10:00:00.000Z"
}

Test-delivery characteristics

  • Timeout: 5 seconds
  • Method: POST
  • Content type: application/json
  • User-Agent: steve-webhook/1.0
  • Signature header: not included on test requests

If the remote endpoint returns a non-2xx status, Steve reports success: false and includes the remote statusCode.

Production delivery contract

The backend defines the following session-result delivery contract for apiSessions with a stored webhookUrl:

{
  "sessionId": "jx7ses789...",
  "status": "pending_review",
  "companyId": "jx7abc123...",
  "result": {
    "example": "opaque result payload"
  },
  "processedAt": "2026-03-22T10:05:30.000Z"
}

Important current behavior

  • The live payload does not include a type field.
  • result is whatever was cached on the session record.
  • webhookDeliveryStatus is tracked internally as pending, delivered, or failed.
  • The public webhook test endpoint is live today; full result delivery becomes relevant once session-producing ingestion routes are published.

Signature verification

Production webhook requests include:

X-Webhook-Signature: sha256=<hex>

The implementation signs the raw JSON payload with HMAC-SHA256 using this secret:

SHA-256(apiKey) rendered as a lowercase hex string

That means consumers must derive the hex string first, then use that string as the HMAC key.

Node.js verification

const crypto = require("crypto");
 
function verifyWebhook(rawBody, signatureHeader, apiKey) {
  const secret = crypto.createHash("sha256").update(apiKey).digest("hex");
  const expected =
    "sha256=" + crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
 
  return crypto.timingSafeEqual(
    Buffer.from(expected, "utf8"),
    Buffer.from(signatureHeader, "utf8"),
  );
}

Python verification

import hashlib
import hmac
 
def verify_webhook(raw_body: bytes, signature_header: str, api_key: str) -> bool:
    secret = hashlib.sha256(api_key.encode("utf-8")).hexdigest()
    expected = "sha256=" + hmac.new(
        secret.encode("utf-8"),
        raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, signature_header)

Retry behavior

Steve gives each production delivery attempt 10 seconds to complete. If the target does not return a 2xx response in time:

  • the initial attempt fails immediately
  • retry 1 is scheduled 30 seconds later
  • retry 2 is scheduled 5 minutes later
  • after the third failed attempt total, delivery status becomes failed

Your endpoint should therefore be idempotent. A practical idempotency key is:

sessionId + status

Treat processedAt as delivery metadata rather than a deduplication key, because it is regenerated for each delivery attempt.

URL validation rules

Webhook URLs must:

  • be valid absolute URLs
  • use https://
  • not target localhost
  • not target 127.0.0.1
  • not target RFC1918 10.x, 192.168.x, or 172.16.x through 172.31.x
  • not end in .internal
  • not end in .local

On this page