Skip to main content
When a user requests a scope grant via POST /v1/session/stepup/request, Prelude calls your step-up hook — the signal_hook_url you registered in the step-up configuration. Your hook decides whether to grant the scope immediately, require a multi-step challenge, or block the request.

Hook request

Prelude sends a signed POST request to your hook URL with the following JSON body:
{
  "scope_requested": "transfer:write",
  "user_id": "usr_39CfbdV8AsXQwdphtbJe4yH07aF",
  "identifiers": [
    {
      "type": "email_address",
      "value": "user@example.com"
    },
    {
      "type": "phone_number",
      "value": "+33612345678"
    }
  ],
  "signals": {
    "user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ...",
    "platform": "WEB",
    "ip": "203.0.113.42"
  },
  "metadata": {
    "amount": "500",
    "currency": "USD"
  }
}

Request fields

FieldTypeDescription
scope_requestedstringThe scope the user is requesting.
user_idstringThe Prelude user ID.
identifiersarrayThe user’s identifiers (email addresses, phone numbers).
identifiers[].typestring"email_address" or "phone_number".
identifiers[].valuestringThe identifier value.
signalsobjectContextual signals from the user’s session.
signals.user_agentstringThe user’s browser or app user agent string.
signals.platformstringThe platform (e.g. "WEB", "ANDROID", "IOS").
signals.ipstringThe user’s IP address.
metadataobjectOptional metadata passed by the frontend when requesting the scope. Max 5 fields, keys max 12 characters, values max 32 characters.

Request headers

HeaderDescription
Content-Typeapplication/json
User-AgentPrelude-SessionStepUpHook/1.0
X-Webhook-SignatureBase64 URL-encoded RSASSA-PSS SHA-256 signature of the request body.
X-Webhook-Signature-Key-IdThe ID of the signing key used to produce the signature.

Request signature

The hook request is signed using the same mechanism as webhooks. Verify the X-Webhook-Signature header using the public key matching the X-Webhook-Signature-Key-Id from your application’s JWKS endpoint.

Hook response

Your endpoint must return a JSON response with a verdict.

Grant immediately

Return status: "continue" to grant the scope without any challenge:
{
  "status": "continue",
  "granted_for": 3600,
  "grant_mode": "session-bound"
}

Require a challenge

Return status: "review" with one or more steps the user must complete:
{
  "status": "review",
  "granted_for": 180,
  "grant_mode": "single-use",
  "steps": [
    {
      "order": 1,
      "key": "verify_sms",
      "expiration_duration": 600
    },
    {
      "order": 2,
      "key": "kyc_review",
      "expiration_duration": 300
    }
  ]
}

Block the request

Return status: "block" to deny the scope entirely:
{
  "status": "block"
}

Response fields

FieldTypeRequiredDescription
statusstringYes"continue", "review", or "block".
granted_forintegerYes (when continue or review)Duration in seconds for which the scope is granted. Must be between 0 and 86400 (24 hours).
grant_modestringYes (when continue or review)"single-use" or "session-bound". See below.
stepsarrayYes (when review)The steps the user must complete, in order. Must not be empty when status is review. Must not be present when status is continue or block.
steps[].orderintegerYesThe position of this step in the sequence (starting from 1).
steps[].keystringYesThe step identifier. Use "verify_sms" or "verify_email" for Prelude-owned steps, or a custom key you registered in the step-up configuration.
steps[].expiration_durationintegerYesTime in seconds the user has to complete this step. Must be between 0 and 86400 (24 hours).

Grant modes

ModeBehavior
single-useThe scope is attached only to the next access token. It is not persisted on the session. granted_for must be at least 1 second.
session-boundThe scope is stored on the session and included in every subsequent refresh for granted_for seconds. If granted_for is less than 1, it defaults to 600 seconds (10 minutes).

Response constraints

RuleLimit
Maximum response body size64 KB
granted_for range0 to 86400 seconds (24 hours). Cannot be negative.
steps with reviewMust contain at least one step.
steps with continue or blockMust not be present.
Step key formatOnly a-z, A-Z, 0-9, and .-_: characters.
Step expiration_duration range0 to 86400 seconds (24 hours). Cannot be negative.

Response HTTP status

Your hook must return HTTP 200 with the JSON body. Any non-200 response or timeout (5 seconds) will cause the step-up request to fail.

Example implementation

Here is a minimal Node.js example of a step-up hook:
app.post("/hooks/stepup", (req, res) => {
  const { scope_requested, user_id, signals, metadata } = req.body;

  // Block requests from unknown IPs
  if (!isKnownIP(signals.ip)) {
    return res.json({ status: "block" });
  }

  // High-value transfers require SMS verification + KYC
  if (scope_requested === "transfer:write" && parseInt(metadata?.amount) > 1000) {
    return res.json({
      status: "review",
      granted_for: 120,
      grant_mode: "single-use",
      steps: [
        { order: 1, key: "verify_sms", expiration_duration: 600 },
        { order: 2, key: "kyc_review", expiration_duration: 300 }
      ]
    });
  }

  // Low-risk operations: grant directly
  return res.json({
    status: "continue",
    granted_for: 3600,
    grant_mode: "session-bound"
  });
});

Custom steps and verification tokens

If your hook returns custom step keys (anything other than verify_sms or verify_email), your backend must issue verification tokens to advance the challenge. See Custom Steps for the full verification token format, requirements, and code examples.