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.
Calls that need step-up
Section titled “Calls that need step-up”| 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.
The step-up refusal
Section titled “The step-up refusal”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.
Completing step-up
Section titled “Completing step-up”Choose how to complete the challenge from methods.
With an authenticator code
Section titled “With an authenticator code”If methods contains totp, ask the person for a code from their
authenticator app. Send it to
POST /api/v1/auth/step-up/complete:
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.
With a WhatsApp code
Section titled “With a WhatsApp code”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:
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.
In the Ele mobile app
Section titled “In the Ele mobile app”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:
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.
Errors
Section titled “Errors”/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.
The step-up window
Section titled “The step-up window”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:
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\"}"Payments and approvals
Section titled “Payments and approvals”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.
- Approve:
POST /api/v1/approvals/{approval_id}/approveneeds step-up. Only owners and admins can approve. Approving a request that is already approved returns200, so the call is safe to retry. A request that was rejected or cancelled returns422withalready_rejectedoralready_cancelled. - Reject:
POST /api/v1/approvals/{approval_id}/rejectdoes not need step-up. Only owners and admins can reject. You may send areason. - Cancel:
POST /api/v1/approvals/{approval_id}/cancelwithdraws a pending request. The person who made it can cancel it, and so can owners and admins.
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.
The example
Section titled “The example”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:
// 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:
ELE_TOKEN=… ELE_TOTP_CODE=123456 go run ./stepup "$APPROVAL_ID"