Agent accounts
An agent is an OpenSea account, and ownership is a declaration two accounts confirm to each other. Declare one and run the handshake over the REST API.
An agent on OpenSea is an account. It has an address, a username, a profile, and a portfolio, the same as any other account, plus a flag saying it is run by software rather than by a person. Anyone can set that flag on their own account through the API.
Ownership is separate. It is a relationship between two accounts, one on the agent side and one on the owner side, and both have to confirm it before anyone else can see it. That is the whole model.
What this is not
An agent is not a sub-account. There is no new account type, nothing appears in the account switcher, and an owner does not gain a second profile. The two accounts are peers that have said something about each other.
It is not delegation. Confirming that X is your agent gives X no ability to act for you: no signing, no listing, no spending, no reading your private data. Delegation is a separate problem that needs different primitives. This is a declaration and nothing else.
It is not verification. Both sides are self-reported. OpenSea does not check that the account is really an agent, or that the owner is really the operator. Treat a confirmed relationship as two accounts agreeing on a label, not as a claim OpenSea has looked into.
Rules of the model
An agent can have no owner. A self-launched agent that nobody declared is a valid agent account, so an empty owner field is the ordinary case rather than an error.
An agent has at most one confirmed owner, so "agent of X" is unambiguous. Several would-be owners can have proposals outstanding at the same time, but only one of them can be confirmed. An owner can have any number of agents.
Either side can withdraw a proposal or revoke a confirmed relationship at any time, without the other side agreeing. Removal deletes the relationship rather than marking it inactive, and either party can propose again afterwards.
Only confirmed relationships are public. A pending proposal is visible to the two accounts involved and to nobody else. This is what stops an agent naming a well-known account as its owner and getting the association shown on a profile that never agreed to it.
Declaring is API-only on purpose. There is no control in the OpenSea interface that marks an account an agent, and the inconvenience is the point: the declaration should cost a deliberate API call rather than a stray click.
The handshake
Either side proposes and the other confirms.
- One account calls propose, naming the counterparty's address and saying which side of the relationship it is on itself.
- The relationship is created with status
PENDING_AGENTorPENDING_OWNER, whichever names the party that has not agreed yet. - The other account calls confirm with the same pair. Status becomes
CONFIRMEDand the relationship becomes public.
Proposing a relationship that is already awaiting you confirms it. A client that cannot tell whether the counterparty moved first can just call propose and get the right outcome either way. Re-proposing a relationship that is waiting on the other side changes nothing, and re-proposing one that is already confirmed leaves it alone, so retries are safe.
caller_role says which side the caller is on, not which side the counterparty is on. AGENT means "I am an agent and this account owns me". OWNER means "I own this account, which is an agent". Your own account never appears in a request body; it comes from the token.
Two accounts can hold a relationship in each direction at once, which is why the role travels with every call. Without it, "my relationship with B" would not name a single row.
Pending proposals expire after seven days. A confirmed relationship has no expiry and lasts until a party revokes it.
Authentication
Every endpoint here needs an API key. All of them except the public profile read also need a wallet JWT, which is how OpenSea establishes that the caller controls the account it is acting on. See Authentication for the SIWE and OAuth flows that produce one, and API keys for the key itself.
The scopes differ between the writes and the read. Declaring, proposing, confirming, and revoking all need write:wallets. Listing your own relationships needs read:wallets.
A client that drives the whole handshake and then reads back the result needs both. With only write:wallets, propose and confirm succeed and the list call returns 403 Insufficient permissions, which is a confusing way to discover a missing scope. Request both up front.
You do not need a private key to hold one of these tokens. The CLI's default login is a browser flow, so signing in as the account is enough:
npm install -g @opensea/cli
opensea login --scopes read:wallets,write:walletsThat runs an OAuth 2.1 authorization-code exchange with PKCE and stores the resulting token, and these endpoints accept it the same way they accept one derived from a SIWE signature. opensea login --private-key remains available for an agent running unattended, where a browser step is the thing you are trying to avoid.
Both accounts authenticate separately. Confirming is a call the counterparty makes with its own token, so a relationship cannot be completed from one side no matter which scopes that side holds.
Endpoints
All paths are on https://api.opensea.io.
| Method | Path | What it does | Auth |
|---|---|---|---|
PUT | /api/v2/accounts/agent | Declare the authenticated account an agent | write:wallets |
DELETE | /api/v2/accounts/agent | Withdraw the declaration | write:wallets |
POST | /api/v2/accounts/agent-relationships | Propose a relationship | write:wallets |
POST | /api/v2/accounts/agent-relationships/confirm | Confirm a proposal made to you | write:wallets |
DELETE | /api/v2/accounts/agent-relationships | Withdraw a proposal or revoke a confirmed relationship | write:wallets |
GET | /api/v2/accounts/agent-relationships | List your own relationships, pending included | read:wallets |
GET | /api/v2/accounts/{address_or_username}/agent-relationships | Read a profile's public relationships | API key only |
These endpoints are metered more tightly than the headline read and write rates, and the writes more tightly than the reads. Read X-RateLimit-Remaining and back off on 429 rather than assuming a fixed budget.
Declare an agent account
curl -X PUT "https://api.opensea.io/api/v2/accounts/agent" \
-H "X-API-KEY: $OPENSEA_API_KEY" \
-H "Authorization: Bearer $OPENSEA_WALLET_JWT"{
"is_agent": true,
"changed": true
}changed is false when the account already had that status. A retry and a real change return the same is_agent, so changed is how you tell them apart.
DELETE on the same path withdraws the declaration and returns "is_agent": false. Withdrawing does not touch any relationships the account is party to.
Propose a relationship
The agent side proposing that a given account owns it:
curl -X POST "https://api.opensea.io/api/v2/accounts/agent-relationships" \
-H "X-API-KEY: $OPENSEA_API_KEY" \
-H "Authorization: Bearer $OPENSEA_WALLET_JWT" \
-H "Content-Type: application/json" \
-d '{
"counterparty_address": "0x2f1a5ce9a2fbcefd2cc3e5c0d5c19b1e93c0f4a7",
"caller_role": "AGENT"
}'{
"relation": {
"initiator_address": "0x8ba1f109551bd432803012645fac136c94c19d6e",
"counterparty_address": "0x2f1a5ce9a2fbcefd2cc3e5c0d5c19b1e93c0f4a7",
"agent_account_id": "3f2a9c1e4b7d48c2a1e5f0b6d9c3a72e",
"owner_account_id": "b41d7c05e28f4a1390d6f2ac81b5e934",
"status": "PENDING_OWNER",
"initiated_by": "AGENT",
"awaiting_confirmation_from": "OWNER",
"created_at": 1787241600.482913,
"confirmed_at": null
},
"created": true
}created is true only for a relationship this call brought into being. A repeat proposal returns the existing relationship with created set to false.
For the owner side, send "caller_role": "OWNER" with the agent's address as the counterparty. The resulting status is PENDING_AGENT instead, because the agent is then the side that has not agreed.
Confirm
The counterparty confirms with the same pair, naming its own side:
curl -X POST "https://api.opensea.io/api/v2/accounts/agent-relationships/confirm" \
-H "X-API-KEY: $OPENSEA_API_KEY" \
-H "Authorization: Bearer $OPENSEA_WALLET_JWT" \
-H "Content-Type: application/json" \
-d '{
"counterparty_address": "0x8ba1f109551bd432803012645fac136c94c19d6e",
"caller_role": "OWNER"
}'{
"relation": {
"initiator_address": "0x8ba1f109551bd432803012645fac136c94c19d6e",
"counterparty_address": "0x2f1a5ce9a2fbcefd2cc3e5c0d5c19b1e93c0f4a7",
"agent_account_id": "3f2a9c1e4b7d48c2a1e5f0b6d9c3a72e",
"owner_account_id": "b41d7c05e28f4a1390d6f2ac81b5e934",
"status": "CONFIRMED",
"initiated_by": "AGENT",
"awaiting_confirmation_from": null,
"created_at": 1787241600.482913,
"confirmed_at": 1787245200.117044
},
"created": false
}initiator_address and initiated_by keep pointing at whoever proposed, so they do not change on confirmation. Confirming a proposal you made yourself is rejected: consent has to come from the other side.
Read your own relationships
curl "https://api.opensea.io/api/v2/accounts/agent-relationships" \
-H "X-API-KEY: $OPENSEA_API_KEY" \
-H "Authorization: Bearer $OPENSEA_WALLET_JWT"{
"relationships": [
{
"initiator_address": "0x8ba1f109551bd432803012645fac136c94c19d6e",
"counterparty_address": "0x2f1a5ce9a2fbcefd2cc3e5c0d5c19b1e93c0f4a7",
"agent_account_id": "3f2a9c1e4b7d48c2a1e5f0b6d9c3a72e",
"owner_account_id": "b41d7c05e28f4a1390d6f2ac81b5e934",
"status": "CONFIRMED",
"initiated_by": "AGENT",
"awaiting_confirmation_from": null,
"created_at": 1787241600.482913,
"confirmed_at": 1787245200.117044
}
]
}This is the only place a pending proposal appears, because it is the only surface where the caller is necessarily one of the two parties. It needs read:wallets, not write:wallets.
Relationship fields
| Field | Type | Notes |
|---|---|---|
initiator_address | string or null | The address that proposed. Null on relationships created before addresses were recorded. |
counterparty_address | string or null | The address the initiator named. Null on the same older relationships. |
agent_account_id | string | Account ID of the agent side. |
owner_account_id | string | Account ID of the owner side. |
status | string | PENDING_AGENT, PENDING_OWNER, or CONFIRMED. |
initiated_by | string | AGENT or OWNER, the side that proposed. |
awaiting_confirmation_from | string or null | AGENT or OWNER while pending, null once confirmed. |
created_at | number | Unix timestamp in seconds, including fractional seconds. |
confirmed_at | number or null | Same format. Null while pending. |
Read status. awaiting_confirmation_from is derived from it and exists so a client driving the handshake does not have to re-derive which side it is waiting on. The two cannot disagree.
Read a profile's public relationships
This one takes an API key and nothing else, and accepts an address or a username:
curl "https://api.opensea.io/api/v2/accounts/0x8ba1f109551bd432803012645fac136c94c19d6e/agent-relationships" \
-H "X-API-KEY: $OPENSEA_API_KEY"{
"agent_owner": {
"address": "0x2f1a5ce9a2fbcefd2cc3e5c0d5c19b1e93c0f4a7",
"username": "example-owner",
"display_name": "example-owner",
"profile_image_url": "https://i.seadn.io/...",
"is_verified": false
},
"agents": []
}agent_owner is the account confirmed to own this one, or null when there is none. agents holds the accounts this one is the confirmed owner of, newest relationship first, and is empty when there are none. Both read confirmed relationships only, so a profile with an outstanding proposal looks exactly like a profile with nothing at all.
agents is capped at 100 entries and the response carries no total, so treat it as what a profile can usefully show rather than as a complete list for an owner running more agents than that.
The account object itself also carries is_agent, so GET /api/v2/accounts/{address_or_username} tells you whether an account claims to be an agent without a second call.
Remove a relationship
Revoke takes query parameters rather than a request body, because fetch, OkHttp, and urllib all drop a DELETE body by default and proxies may strip it:
curl -X DELETE "https://api.opensea.io/api/v2/accounts/agent-relationships?counterparty_address=0x2f1a5ce9a2fbcefd2cc3e5c0d5c19b1e93c0f4a7&caller_role=AGENT" \
-H "X-API-KEY: $OPENSEA_API_KEY" \
-H "Authorization: Bearer $OPENSEA_WALLET_JWT"{
"removed": true
}The same call withdraws a proposal and revokes a confirmed relationship: to the API these are the same row. Naming a counterparty and role that match nothing you are party to returns 404, so a successful call almost always reports "removed": true. A false comes back only if the relationship disappeared between the lookup and the delete, which happens when the other side revoked it at the same moment.
Removal deletes the relationship. There is no history to read afterwards and no tombstone, and the pair is free to propose again.
Errors
| Status | Cause |
|---|---|
400 | The address or caller_role is malformed, both sides resolve to the same account, the relationship is not awaiting confirmation from you, or you have too many proposals outstanding. |
401 | Missing or expired wallet JWT. |
403 | The token is missing write:wallets, or read:wallets on the list endpoint. |
404 | No account for that counterparty, or no relationship between you and that counterparty on that side. |
409 | The relationship changed while the call was in flight. Read it back and retry. |
429 | Rate limited. Wait for Retry-After. |
On confirm and revoke, an unknown counterparty and an absent relationship both return 404 with the same message. Telling them apart would turn the endpoint into a way to check whether an address has an OpenSea account.
An account cannot be its own agent, and that check runs on accounts rather than addresses, so naming a second wallet of your own as the counterparty is rejected too.
There is a cap on how many proposals you can have outstanding, counted separately for the ones you raised and the ones waiting on you. Hitting either returns 400 with the limits in the message. Answering or withdrawing outstanding proposals clears it, and untouched proposals lapse after seven days.
Next steps
- Build with AI agents: MCP, Agent Skills, CLI, and SDK integration paths
- Set up an onchain agent: platform, LLM, and wallet setup for an agent that transacts
- Authentication: getting the wallet JWT these endpoints need
Updated about 5 hours ago
