Skip to content

Step-up and approvals

Some calls change who can move money or reach an account. For those, a valid session is not enough: the person must confirm, right then, that they are present. This is step-up.

A person must complete each step-up. Your server can ask for it and pass on the person’s code, but it must not answer step-up by itself. Some operations would let it do so; Keep a person in the loop lists them.

Operation Call
approveRequest POST /api/v1/approvals/{approval_id}/approve
executeSwap POST /api/v1/swaps/execute
updateMember PATCH /api/v1/organizations/{organization_id}/members/{member_id}
removeMember DELETE /api/v1/organizations/{organization_id}/members/{member_id}
createApiKey POST /api/v1/organizations/{organization_id}/api-keys
updateApiKey PATCH /api/v1/api-keys/{api_key_id}
rotateApiKey POST /api/v1/api-keys/{api_key_id}/rotate
unfreezeCard POST /api/v1/cards/{card_id}/unfreeze
activateCard POST /api/v1/cards/{card_id}/activate
setCardPin POST /api/v1/cards/{card_id}/pin
orderPhysicalCard POST /api/v1/cards/{card_id}/physical-order
googleWalletProvision POST /api/v1/cards/{card_id}/google-wallet-provision
setPrimaryEmail PUT /api/v1/me/primary-email
removeDevice DELETE /api/v1/devices/{device_id}
enrollApprovalCredential POST /api/v1/me/approval-credential

Creating a payment does not need step-up. Approving it does.

enrollApprovalCredential is listed because it needs step-up, not because your server should call it. It sets up a phone to approve for the person; only Ele’s mobile app should use it.

An organization API key can never complete step-up, so none of these calls works with a key.

When a call needs step-up and the session has none open, Ele refuses it with 403 and the error type step_up_required. The step_up object describes the challenge:

{
"error": {
"type": "step_up_required",
"message": "This action requires re-verification.",
"request_id": "…",
"step_up": {
"id": "0190f1c2-…",
"step_up_authentication_id": "0190f1c3-…",
"methods": ["totp", "webauthn"],
"delivery": "webauthn",
"webauthn": { "…": "…" },
"expires_at": null,
"match_code": "417"
}
}
}
Field Meaning
id The challenge to complete. Send it as sudo_session_id.
step_up_authentication_id A different ID. Send it only to the fallback call.
methods The ways this person can complete the challenge: totp, whatsapp, webauthn or app_approval.
delivery The method Ele started with.
webauthn Passkey options, when delivery is webauthn. Your server cannot use them.
expires_at When the challenge itself expires, if it has a limit.
match_code A 3-digit code. Show it to the person if they approve in the Ele mobile app.

Retrying the call before the challenge is complete returns the same challenge.

Branch on the error type. Do not branch on the 403 status alone, because other refusals use 403 too.

If the person has no method strong enough for the call, Ele can answer 403 with the type step_up_enrollment_required instead. They need to set up an authenticator app first. GET /api/v1/me lists the methods that can clear a step-up in step_up_methods. An empty list means step-up calls will be refused this way.

Choose how to complete the challenge from methods.

If methods contains totp, ask the person for a code from their authenticator app. Send it to POST /api/v1/auth/step-up/complete:

Terminal window
curl -s "$ELE_API_BASE/api/v1/auth/step-up/complete" \
-H "Authorization: Bearer $ELE_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"sudo_session_id\": \"$STEP_UP_ID\", \"code\": \"$ELE_TOTP_CODE\"}"
{ "active": true, "expires_at": "2026-10-09T10:05:00Z" }

This works even when delivery is webauthn. Each code works only once, so a code used to sign in cannot be used again for step-up. The person waits for the next one.

If methods contains whatsapp, Ele can send the person a code on WhatsApp. When delivery is already whatsapp, the code has been sent. When methods also contains webauthn but not totp, and delivery is webauthn, ask Ele to send it with POST /api/v1/auth/step-up/fallback:

Terminal window
curl -s "$ELE_API_BASE/api/v1/auth/step-up/fallback" \
-H "Authorization: Bearer $ELE_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"step_up_authentication_id\": \"$STEP_UP_AUTHENTICATION_ID\"}"

Ele sends the code once per challenge. The response’s code_expires_at says how long it lasts. If methods contains totp, Ele refuses the fallback with 422 and the type whatsapp_fallback_not_allowed; use the authenticator code. The person gives you the code, and you send it to /api/v1/auth/step-up/complete as above.

If methods contains app_approval, the person can approve on their own phone in the Ele mobile app. Show them the match_code; the app asks them to enter it. Meanwhile, poll GET /api/v1/auth/step-up/{sudo_session_id} every few seconds:

Terminal window
curl -s "$ELE_API_BASE/api/v1/auth/step-up/$STEP_UP_ID" \
-H "Authorization: Bearer $ELE_TOKEN"

Its status is pending while you wait, active once the person has approved, declined if they said they did not ask for it, or expired. Stop polling on anything but pending.

/api/v1/auth/step-up/complete returns 422 with one of these types:

  • invalid_code: the code is wrong.
  • expired_code: the challenge has expired or was already used.
  • challenge_declined: the person answered that they did not ask for it.

Too many attempts return 429 with the type rate_limited.

Completing step-up opens a window of 5 minutes on that session. Retry the original call within it. During the window, other calls that need step-up usually pass on that session too. If Ele asks for step-up again, complete the new challenge.

When you are done, close the window early with POST /api/v1/auth/step-up/exit, sending the same ID as id:

Terminal window
curl -s "$ELE_API_BASE/api/v1/auth/step-up/exit" \
-H "Authorization: Bearer $ELE_TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"id\": \"$STEP_UP_ID\"}"

A payment does not move money when you create it. createPayment and executePayment return the payment with the status awaiting_approval and an approval_request_id. Money moves only when someone approves that request.

An organization can require a second person to approve. Then the person who created a request cannot approve it, and Ele answers 422 with the type self_approval_not_allowed.

To see what is waiting, list an organization’s requests with GET /api/v1/organizations/{organization_id}/approvals.

This program approves a request. The first attempt is refused with step_up_required. The program completes step-up with the person’s authenticator code, retries, and then closes the window:

stepup/main.go
// Step-up: approve a pending approval request. Approving needs step-up, so
// the first attempt is refused with step_up_required. The example completes
// the challenge with an authenticator code the person supplies, then retries.
//
// ELE_TOKEN=… ELE_TOTP_CODE=123456 go run ./stepup <approval_id>
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/url"
"os"
"slices"
"github.com/elephants-labs/ele/developers/examples/internal/ele"
)
// challenge holds the fields of the step_up object this example uses.
type challenge struct {
ID string `json:"id"`
Methods []string `json:"methods"`
}
type approval struct {
ApprovalID string `json:"approval_id"`
Status string `json:"status"`
}
func main() {
if len(os.Args) != 2 {
log.Fatal("usage: go run ./stepup <approval_id>")
}
ctx := context.Background()
c := ele.FromEnv()
path := "/api/v1/approvals/" + url.PathEscape(os.Args[1]) + "/approve"
var result approval
err := c.Do(ctx, "POST", path, nil, &result)
var apiErr *ele.APIError
if errors.As(err, &apiErr) && apiErr.Type == "step_up_required" {
var ch challenge
if err := json.Unmarshal(apiErr.StepUp, &ch); err != nil {
log.Fatal(err)
}
if !slices.Contains(ch.Methods, "totp") {
log.Fatalf("this person has no authenticator app; available methods: %v", ch.Methods)
}
// The code must come from the person, for this approval.
var window struct {
ExpiresAt string `json:"expires_at"`
}
err := c.Do(ctx, "POST", "/api/v1/auth/step-up/complete", map[string]string{
"sudo_session_id": ch.ID,
"code": os.Getenv("ELE_TOTP_CODE"),
}, &window)
if err != nil {
log.Fatal(err)
}
fmt.Fprintln(os.Stderr, "step-up complete; window open until", window.ExpiresAt)
// Retry the original call. Approving is safe to repeat: an approval
// that is already approved returns 200.
err = c.Do(ctx, "POST", path, nil, &result)
// Close the window now rather than leave it open for its full length.
if exitErr := c.Do(ctx, "POST", "/api/v1/auth/step-up/exit", map[string]string{"id": ch.ID}, nil); exitErr != nil {
fmt.Fprintln(os.Stderr, "closing the step-up window:", exitErr)
}
}
if err != nil {
log.Fatal(err)
}
fmt.Printf("approval %s is %s\n", result.ApprovalID, result.Status)
}

Run it with a session token from the sign-in example and a fresh code:

Terminal window
ELE_TOKEN=… ELE_TOTP_CODE=123456 go run ./stepup "$APPROVAL_ID"