curl --request POST \
--url https://builder.prod.bedrock.ostium.io/v1/onboard \
--header 'Content-Type: application/json' \
--data '
{
"chainId": 42161,
"partner": "0x1111111111111111111111111111111111111111",
"delegation": {
"delegate": "0x2222222222222222222222222222222222222222",
"nonce": "0",
"expiry": "1800000000",
"signature": "0xabcd"
},
"permit": {
"value": "1000000000000",
"nonce": "0",
"deadline": "1800000000",
"signature": "0xabcd"
}
}
'import requests
url = "https://builder.prod.bedrock.ostium.io/v1/onboard"
payload = {
"chainId": 42161,
"partner": "0x1111111111111111111111111111111111111111",
"delegation": {
"delegate": "0x2222222222222222222222222222222222222222",
"nonce": "0",
"expiry": "1800000000",
"signature": "0xabcd"
},
"permit": {
"value": "1000000000000",
"nonce": "0",
"deadline": "1800000000",
"signature": "0xabcd"
}
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
chainId: 42161,
partner: '0x1111111111111111111111111111111111111111',
delegation: {
delegate: '0x2222222222222222222222222222222222222222',
nonce: '0',
expiry: '1800000000',
signature: '0xabcd'
},
permit: {
value: '1000000000000',
nonce: '0',
deadline: '1800000000',
signature: '0xabcd'
}
})
};
fetch('https://builder.prod.bedrock.ostium.io/v1/onboard', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://builder.prod.bedrock.ostium.io/v1/onboard",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'chainId' => 42161,
'partner' => '0x1111111111111111111111111111111111111111',
'delegation' => [
'delegate' => '0x2222222222222222222222222222222222222222',
'nonce' => '0',
'expiry' => '1800000000',
'signature' => '0xabcd'
],
'permit' => [
'value' => '1000000000000',
'nonce' => '0',
'deadline' => '1800000000',
'signature' => '0xabcd'
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://builder.prod.bedrock.ostium.io/v1/onboard"
payload := strings.NewReader("{\n \"chainId\": 42161,\n \"partner\": \"0x1111111111111111111111111111111111111111\",\n \"delegation\": {\n \"delegate\": \"0x2222222222222222222222222222222222222222\",\n \"nonce\": \"0\",\n \"expiry\": \"1800000000\",\n \"signature\": \"0xabcd\"\n },\n \"permit\": {\n \"value\": \"1000000000000\",\n \"nonce\": \"0\",\n \"deadline\": \"1800000000\",\n \"signature\": \"0xabcd\"\n }\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://builder.prod.bedrock.ostium.io/v1/onboard")
.header("Content-Type", "application/json")
.body("{\n \"chainId\": 42161,\n \"partner\": \"0x1111111111111111111111111111111111111111\",\n \"delegation\": {\n \"delegate\": \"0x2222222222222222222222222222222222222222\",\n \"nonce\": \"0\",\n \"expiry\": \"1800000000\",\n \"signature\": \"0xabcd\"\n },\n \"permit\": {\n \"value\": \"1000000000000\",\n \"nonce\": \"0\",\n \"deadline\": \"1800000000\",\n \"signature\": \"0xabcd\"\n }\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://builder.prod.bedrock.ostium.io/v1/onboard")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"chainId\": 42161,\n \"partner\": \"0x1111111111111111111111111111111111111111\",\n \"delegation\": {\n \"delegate\": \"0x2222222222222222222222222222222222222222\",\n \"nonce\": \"0\",\n \"expiry\": \"1800000000\",\n \"signature\": \"0xabcd\"\n },\n \"permit\": {\n \"value\": \"1000000000000\",\n \"nonce\": \"0\",\n \"deadline\": \"1800000000\",\n \"signature\": \"0xabcd\"\n }\n}"
response = http.request(request)
puts response.read_body{
"status": "confirmed",
"userOpHash": "0xaaaa",
"transactionHash": "0xbbbb",
"state": {
"onboarded": true,
"missing": [],
"delegate": "0x2222222222222222222222222222222222222222",
"allowance": "115792089237316195423570985008687907853269984665640564039457584007913129639935"
}
}{
"error": "Bad Request",
"message": "Validation failed",
"issues": []
}{
"error": "Unauthorized",
"message": "permit signature does not match the partner address"
}{
"error": "Forbidden",
"message": "address is not a permitted partner"
}{
"error": "Payload Too Large",
"message": "request entity too large"
}{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again in 7s."
}{
"error": "Internal Server Error",
"message": "Internal Server Error"
}One-time onboarding (gasless)
A one-time setup step, done gaslessly. Sign two EIP-712 messages offline, post the signatures here, and we relay them on chain and pay for it — the partner never sends a transaction or needs ETH.
Restricted to allowlisted partner addresses. Contact us before integrating.
Send whichever parts you still need; at least one. They are relayed as a single atomic batch, so you are never left with an allowance but no delegate. The response reads both values back from chain, so state.missing reflects what is actually set rather than what we submitted.
The two signatures
delegation lets the OstiumAtomicTrading wrapper place trades for you.
- Domain:
{ name: "Ostium", version: "1", chainId, verifyingContract: <Trading> } - Type:
OstiumDelegation(address delegator, address delegate, uint256 nonce, uint256 expiry) - The verifying contract is Trading, but
delegateis the wrapper — it is the contract that will call Trading for you, so it is what Trading checks. Sending anything else is rejected.
permit grants the USDC allowance (ERC-2612).
- Domain:
{ name, version, chainId, verifyingContract: <USDC> } - Type:
Permit(address owner, address spender, uint256 value, uint256 nonce, uint256 deadline) spenderis TradingStorage, not Trading — different contracts.- Read
nameandversionfrom the token itself; they differ between deployments and a wrong domain produces a signature that fails on chain.
Both nonces come from the chain at signing time — Trading.delegatableNonces(partner) and USDC.nonces(partner).
Addresses
| Arbitrum (42161) | Arbitrum Sepolia (421614) | |
|---|---|---|
| Trading | 0x6D0bA1f9996DBD8885827e1b2e8f6593e7702411 | 0x2A9B9c988393f46a2537B0ff11E98c2C15a95afe |
| TradingStorage (permit spender) | 0xccd5891083a8acd2074690f65d3024e7d13d66e7 | 0x0b9F5243B29938668c9Cfbd7557A389EC7Ef88b8 |
| USDC | 0xaf88d065e77c8cc2239327c5edb3a432268e5831 | 0xe73B11Fb1e3eeEe8AF2a23079A4410Fe1B370548 |
| OstiumAtomicTrading (delegate) | 0x5eB3960C3fd3274cD81fE5972e0de01084bDa325 | 0x32C06a3eC2A40DABf6A8f645f29321cB7236DAA3 |
Two cases where you send only one half
On Arbitrum Sepolia there is no permit. The test token is a plain ERC-20 with no permit or nonces, so no permit signature exists for it. Send approve(<TradingStorage>, <amount>) from the wallet, then post delegation alone. Mainnet USDC does implement ERC-2612.
A contract wallet can permit but not delegate. setDelegateWithSignature uses ecrecover, which no EIP-1271 signature satisfies. Call Trading.setDelegate(<OstiumAtomicTrading>) from the wallet, then post permit alone.
Notes
- Keep
expiryanddeadlineshort — 30 minutes is plenty. Until one passes, anyone holding the signature can relay it. They cannot redirect it:delegate,spenderandvalueare all inside the signed bytes. - Numbers are decimal strings, never JSON numbers. A uint256 nonce or a max allowance exceeds
Number.MAX_SAFE_INTEGER, and a rounded value no longer matches what you signed.
Rate limit: 10 requests per 10 seconds per IP. Read x-ratelimit-* for the live budget rather than assuming this figure.
curl --request POST \
--url https://builder.prod.bedrock.ostium.io/v1/onboard \
--header 'Content-Type: application/json' \
--data '
{
"chainId": 42161,
"partner": "0x1111111111111111111111111111111111111111",
"delegation": {
"delegate": "0x2222222222222222222222222222222222222222",
"nonce": "0",
"expiry": "1800000000",
"signature": "0xabcd"
},
"permit": {
"value": "1000000000000",
"nonce": "0",
"deadline": "1800000000",
"signature": "0xabcd"
}
}
'import requests
url = "https://builder.prod.bedrock.ostium.io/v1/onboard"
payload = {
"chainId": 42161,
"partner": "0x1111111111111111111111111111111111111111",
"delegation": {
"delegate": "0x2222222222222222222222222222222222222222",
"nonce": "0",
"expiry": "1800000000",
"signature": "0xabcd"
},
"permit": {
"value": "1000000000000",
"nonce": "0",
"deadline": "1800000000",
"signature": "0xabcd"
}
}
headers = {"Content-Type": "application/json"}
response = requests.post(url, json=payload, headers=headers)
print(response.text)const options = {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
chainId: 42161,
partner: '0x1111111111111111111111111111111111111111',
delegation: {
delegate: '0x2222222222222222222222222222222222222222',
nonce: '0',
expiry: '1800000000',
signature: '0xabcd'
},
permit: {
value: '1000000000000',
nonce: '0',
deadline: '1800000000',
signature: '0xabcd'
}
})
};
fetch('https://builder.prod.bedrock.ostium.io/v1/onboard', options)
.then(res => res.json())
.then(res => console.log(res))
.catch(err => console.error(err));<?php
$curl = curl_init();
curl_setopt_array($curl, [
CURLOPT_URL => "https://builder.prod.bedrock.ostium.io/v1/onboard",
CURLOPT_RETURNTRANSFER => true,
CURLOPT_ENCODING => "",
CURLOPT_MAXREDIRS => 10,
CURLOPT_TIMEOUT => 30,
CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
CURLOPT_CUSTOMREQUEST => "POST",
CURLOPT_POSTFIELDS => json_encode([
'chainId' => 42161,
'partner' => '0x1111111111111111111111111111111111111111',
'delegation' => [
'delegate' => '0x2222222222222222222222222222222222222222',
'nonce' => '0',
'expiry' => '1800000000',
'signature' => '0xabcd'
],
'permit' => [
'value' => '1000000000000',
'nonce' => '0',
'deadline' => '1800000000',
'signature' => '0xabcd'
]
]),
CURLOPT_HTTPHEADER => [
"Content-Type: application/json"
],
]);
$response = curl_exec($curl);
$err = curl_error($curl);
curl_close($curl);
if ($err) {
echo "cURL Error #:" . $err;
} else {
echo $response;
}package main
import (
"fmt"
"strings"
"net/http"
"io"
)
func main() {
url := "https://builder.prod.bedrock.ostium.io/v1/onboard"
payload := strings.NewReader("{\n \"chainId\": 42161,\n \"partner\": \"0x1111111111111111111111111111111111111111\",\n \"delegation\": {\n \"delegate\": \"0x2222222222222222222222222222222222222222\",\n \"nonce\": \"0\",\n \"expiry\": \"1800000000\",\n \"signature\": \"0xabcd\"\n },\n \"permit\": {\n \"value\": \"1000000000000\",\n \"nonce\": \"0\",\n \"deadline\": \"1800000000\",\n \"signature\": \"0xabcd\"\n }\n}")
req, _ := http.NewRequest("POST", url, payload)
req.Header.Add("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(string(body))
}HttpResponse<String> response = Unirest.post("https://builder.prod.bedrock.ostium.io/v1/onboard")
.header("Content-Type", "application/json")
.body("{\n \"chainId\": 42161,\n \"partner\": \"0x1111111111111111111111111111111111111111\",\n \"delegation\": {\n \"delegate\": \"0x2222222222222222222222222222222222222222\",\n \"nonce\": \"0\",\n \"expiry\": \"1800000000\",\n \"signature\": \"0xabcd\"\n },\n \"permit\": {\n \"value\": \"1000000000000\",\n \"nonce\": \"0\",\n \"deadline\": \"1800000000\",\n \"signature\": \"0xabcd\"\n }\n}")
.asString();require 'uri'
require 'net/http'
url = URI("https://builder.prod.bedrock.ostium.io/v1/onboard")
http = Net::HTTP.new(url.host, url.port)
http.use_ssl = true
request = Net::HTTP::Post.new(url)
request["Content-Type"] = 'application/json'
request.body = "{\n \"chainId\": 42161,\n \"partner\": \"0x1111111111111111111111111111111111111111\",\n \"delegation\": {\n \"delegate\": \"0x2222222222222222222222222222222222222222\",\n \"nonce\": \"0\",\n \"expiry\": \"1800000000\",\n \"signature\": \"0xabcd\"\n },\n \"permit\": {\n \"value\": \"1000000000000\",\n \"nonce\": \"0\",\n \"deadline\": \"1800000000\",\n \"signature\": \"0xabcd\"\n }\n}"
response = http.request(request)
puts response.read_body{
"status": "confirmed",
"userOpHash": "0xaaaa",
"transactionHash": "0xbbbb",
"state": {
"onboarded": true,
"missing": [],
"delegate": "0x2222222222222222222222222222222222222222",
"allowance": "115792089237316195423570985008687907853269984665640564039457584007913129639935"
}
}{
"error": "Bad Request",
"message": "Validation failed",
"issues": []
}{
"error": "Unauthorized",
"message": "permit signature does not match the partner address"
}{
"error": "Forbidden",
"message": "address is not a permitted partner"
}{
"error": "Payload Too Large",
"message": "request entity too large"
}{
"error": "Too Many Requests",
"message": "Rate limit exceeded. Try again in 7s."
}{
"error": "Internal Server Error",
"message": "Internal Server Error"
}Body
42161 (Arbitrum One) or 421614 (Arbitrum Sepolia)
42161 The address being onboarded, and the one that signed below.
^0x[a-fA-F0-9]{40}$Lets the wrapper trade for you. Omit if you have already delegated.
Show child attributes
Show child attributes
Grants the USDC allowance. Omit if you have already approved, and on Arbitrum Sepolia, where the test token has no permit.
Show child attributes
Show child attributes
Response
Relayed. status is the terminal on-chain outcome, not an acknowledgement.
confirmed means both signatures applied on chain. reverted means neither did — the batch is atomic.
confirmed, reverted Identifies the ERC-4337 user operation we submitted for you. Quote it if you need to ask us about a specific call.
"0x8c8b184c3208dd11c0275a0cd5a62d0ef0fb4578d1f9b36569b2d346048af4f7"
The transaction it landed in — look it up on Arbiscan.
"0x9cae473bf1c8d54da1d32138e7bbe1d2ca05213c98fe70945b402634aac13279"
Why it reverted. Present only when status is reverted.
Your onboarding state, read back from chain rather than assumed from what was submitted. Absent if that read failed; the outcome above still stands.
Show child attributes
Show child attributes