Skip to main content

Submitting UserOperations

This guide walks through the complete flow for sending an action through a user’s Safe smart account. By the end you will have submitted a sponsored UserOperation, polled it to a terminal status, and read the underlying transaction receipt. For background on what a UserOperation is and how the lifecycle states relate, read Sponsored UserOperations first.

Prerequisites

  • An authenticated user. See the onboarding guide for the user creation flow.
  • An active signer for the user on the target chain. Signers are provisioned during onboarding; see Safes and identity for how the signer relates to the Safe.

Step 1: Discover the Safe

Before constructing a UserOperation you need the Safe address (which becomes the sender) and, if the Safe is not yet deployed on this chain, the factory parameters that will deploy it in the same bundle.
curl "https://api.sumvin.com/v0/safe/config?chain_id=10" \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>"
Response: 200 OK
{
  "factory": "0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67",
  "factory_data": "0x1688f0b9...",
  "predicted_safe_address": "0xE23c9A70BC749EBddd8c78a864fd911D04E9e992",
  "entrypoint": "0x0000000071727De22E5E9d8BAf0edAc6f37da032",
  "chain_id": 10,
  "signer_contract_address": "0xA1b2C3d4E5f6789012345678901234567890aBcD",
  "_links": {
    "self": { "href": "/v0/safe/config?chain_id=10" },
    "rpc": { "href": "/v0/safe/rpc", "method": "POST" }
  }
}
The predicted_safe_address is deterministic from CREATE2. It is the address the Safe will deploy to, and it is usable as a UserOperation sender even before deployment — the first UserOperation can include factory and factoryData to deploy the Safe in the same bundle.

Step 2: Construct the UserOperation

Build the UserOperation client-side. Most ERC-4337 SDKs (e.g., permissionless, viem’s account-abstraction module) handle nonce, gas estimation, and signature production. The full ERC-4337 specification is out of scope for this guide; refer to the EIP for field-by-field semantics. A minimal example:
import { createPublicClient, http } from "viem";
import { optimism } from "viem/chains";
import { createBundlerClient } from "viem/account-abstraction";

const userOperation = {
  sender: predictedSafeAddress,
  nonce: 0n,
  factory: factoryAddress,         // omit on subsequent ops once Safe is deployed
  factoryData: factoryData,        // omit on subsequent ops once Safe is deployed
  callData: encodeExecuteCalldata(target, value, data),
  callGasLimit: 200_000n,
  verificationGasLimit: 350_000n,
  preVerificationGas: 50_000n,
  maxFeePerGas: 1_500_000_000n,
  maxPriorityFeePerGas: 1_000_000_000n,
  signature: await signWithUserSigner(...),
};
Sumvin populates the paymaster fields on your behalf — do not set paymaster, paymasterData, paymasterVerificationGasLimit, or paymasterPostOpGasLimit on the request body.

Step 3: Submit for sponsorship

Send the signed UserOperation to /v0/safe/rpc. Include an Idempotency-Key header so retries are safe.
curl -X POST https://api.sumvin.com/v0/safe/rpc \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>" \
  -H "Idempotency-Key: 7b3d4e5f-1a2b-4c3d-9e8f-1234567890ab" \
  -H "Content-Type: application/json" \
  -d '{
    "chain_id": 10,
    "user_operation": {
      "sender": "0xE23c9A70BC749EBddd8c78a864fd911D04E9e992",
      "nonce": "0x0",
      "factory": "0x4e1DCf7AD4e460CfD30791CCC4F9c8a4f820ec67",
      "factoryData": "0x1688f0b9...",
      "callData": "0x6a7612020000...",
      "callGasLimit": "0x30d40",
      "verificationGasLimit": "0x55730",
      "preVerificationGas": "0xc350",
      "maxFeePerGas": "0x59682f00",
      "maxPriorityFeePerGas": "0x3b9aca00",
      "signature": "0xabc..."
    },
    "entrypoint": "0x0000000071727De22E5E9d8BAf0edAc6f37da032"
  }'
The Idempotency-Key header should be a UUID v4 generated at request time. Agent-originated UserOperations should always include one — LLM retries on a transient network error must not result in double-sponsorship.
Response: 202 Accepted
{
  "user_op_hash": "0x9f1a8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a1b0c9d8e7f6a5b4c3d2e1f0a9b",
  "status": "submitted",
  "_links": {
    "self": { "href": "/v0/safe/rpc", "method": "POST" },
    "status": { "href": "/v0/safe/rpc/0x9f1a8c.../status" }
  }
}
The 202 indicates that Sumvin has accepted the UserOperation and forwarded it to the bundler. It is not a guarantee that the operation will land on-chain.

Idempotency replay

Re-sending the same request body with the same Idempotency-Key returns the original response. The replayed response includes an X-Idempotent-Replay: true response header so your client can distinguish replays from fresh submissions.

Idempotency conflict

Re-using the same Idempotency-Key with a different request body returns 409 Conflict:
{
  "type": "https://api.sumvin.com/errors/saf-409-001",
  "title": "Idempotency Conflict",
  "status": 409,
  "detail": "Idempotency-Key already used with a different request body",
  "instance": "/v0/safe/rpc",
  "error_code": "SAF-409-001"
}

Step 4: Poll for status

Use the _links.status.href from the previous response, or construct the URL directly: GET /v0/safe/rpc/{user_op_hash}/status.
curl https://api.sumvin.com/v0/safe/rpc/0x9f1a8c.../status \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>"
While the operation is still in flight you will see one of not_submitted, queued, or submitted:
{
  "user_op_hash": "0x9f1a8c...",
  "status": "queued",
  "txn_hash": null,
  "block_number": null,
  "gas_used": null,
  "actual_gas_cost_wei": null,
  "success": null,
  "_links": {
    "self": { "href": "/v0/safe/rpc/0x9f1a8c.../status" },
    "rpc": { "href": "/v0/safe/rpc", "method": "POST" }
  }
}

Terminal: included

When the UserOperation succeeds on-chain, the response is fully populated and includes a receipt link:
{
  "user_op_hash": "0x9f1a8c...",
  "status": "included",
  "txn_hash": "0xc0ffee...",
  "block_number": 121845760,
  "gas_used": 187432,
  "actual_gas_cost_wei": 281148000000000,
  "success": true,
  "_links": {
    "self": { "href": "/v0/safe/rpc/0x9f1a8c.../status" },
    "rpc": { "href": "/v0/safe/rpc", "method": "POST" },
    "receipt": { "href": "/v0/transactions/receipts/0xc0ffee..." }
  }
}

Terminal: failed

The bundle landed but this UserOperation reverted on-chain. Same shape as included, but success: false. The transaction hash and receipt link are still populated because gas was burned:
{
  "user_op_hash": "0x9f1a8c...",
  "status": "failed",
  "txn_hash": "0xc0ffee...",
  "block_number": 121845760,
  "gas_used": 95212,
  "actual_gas_cost_wei": 142818000000000,
  "success": false,
  "_links": {
    "self": { "href": "/v0/safe/rpc/0x9f1a8c.../status" },
    "rpc": { "href": "/v0/safe/rpc", "method": "POST" },
    "receipt": { "href": "/v0/transactions/receipts/0xc0ffee..." }
  }
}

Terminal: rejected

The bundler declined the UserOperation pre-submission. No transaction hash, no receipt link, no gas burned:
{
  "user_op_hash": "0x9f1a8c...",
  "status": "rejected",
  "txn_hash": null,
  "block_number": null,
  "gas_used": null,
  "actual_gas_cost_wei": null,
  "success": null,
  "_links": {
    "self": { "href": "/v0/safe/rpc/0x9f1a8c.../status" },
    "rpc": { "href": "/v0/safe/rpc", "method": "POST" }
  }
}
See Sponsored UserOperations for the full distinction between rejected and failed. When _links.receipt is present, GET it to retrieve the full on-chain transaction record — logs, internal calls, and the canonical receipt object — through Sumvin’s transaction history API.
curl https://api.sumvin.com/v0/transactions/receipts/0xc0ffee... \
  -H "x-juno-jwt: <token>" \
  -H "x-juno-orgid: <your-org-id>"

Degraded finalisation: 503 Service Unavailable

For user-signed Safe-deployment UserOperations (the safe_deploy onboarding step under user_signed_deploy), the status-poll endpoint runs an extra “finalise” step after the bundler reports included: it verifies the deployed contract is a Safe with the user EOA as sole owner, persists the wallet, and advances onboarding. Three distinct outcomes:
  • 200 OK with included — finalisation succeeded; the wallet is persisted and onboarding has advanced.
  • 400 with SAF-400-012 (Safe Deploy Verification Failed) — the deployed contract is not a Safe (e.g. wrong factory) or has the wrong owner set. Permanent failure — re-polling will not fix it.
  • 503 with SAF-503-001 (Safe Deploy Finalisation Degraded) — finalisation hit a transient downstream failure (e.g. the verifier RPC is briefly unavailable). The UserOperation is still recorded as included on-chain. The response includes a Retry-After header (default 5 seconds) — clients should pause polling for that duration and retry.
Treat 503 + Retry-After as a polling pause, not a hard failure. The next status poll will re-run finalisation against the same UserOperation hash.

Error reference

CodeStatusWhen
SAF-409-001409 ConflictIdempotency-Key reused with a different request body.
SAF-400-012400 Bad RequestUser-signed Safe deploy: the deployed contract failed verification (not a Safe, wrong owners, or wrong contract version). Permanent — do not retry.
SAF-502-004502 Bad GatewayThe bundler returned a status value Sumvin does not recognise. Treat as transient and retry.
SAF-502-005502 Bad GatewayFailed to fetch the on-chain receipt for an in-flight UserOperation. Retry the poll.
SAF-503-001503 Service UnavailableUser-signed Safe deploy finalisation hit a transient error after the UserOperation was included on-chain. Retry the status poll after the Retry-After interval.

See also