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.

  1. One account calls propose, naming the counterparty's address and saying which side of the relationship it is on itself.
  2. The relationship is created with status PENDING_AGENT or PENDING_OWNER, whichever names the party that has not agreed yet.
  3. The other account calls confirm with the same pair. Status becomes CONFIRMED and 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:wallets

That 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.

MethodPathWhat it doesAuth
PUT/api/v2/accounts/agentDeclare the authenticated account an agentwrite:wallets
DELETE/api/v2/accounts/agentWithdraw the declarationwrite:wallets
POST/api/v2/accounts/agent-relationshipsPropose a relationshipwrite:wallets
POST/api/v2/accounts/agent-relationships/confirmConfirm a proposal made to youwrite:wallets
DELETE/api/v2/accounts/agent-relationshipsWithdraw a proposal or revoke a confirmed relationshipwrite:wallets
GET/api/v2/accounts/agent-relationshipsList your own relationships, pending includedread:wallets
GET/api/v2/accounts/{address_or_username}/agent-relationshipsRead a profile's public relationshipsAPI 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

FieldTypeNotes
initiator_addressstring or nullThe address that proposed. Null on relationships created before addresses were recorded.
counterparty_addressstring or nullThe address the initiator named. Null on the same older relationships.
agent_account_idstringAccount ID of the agent side.
owner_account_idstringAccount ID of the owner side.
statusstringPENDING_AGENT, PENDING_OWNER, or CONFIRMED.
initiated_bystringAGENT or OWNER, the side that proposed.
awaiting_confirmation_fromstring or nullAGENT or OWNER while pending, null once confirmed.
created_atnumberUnix timestamp in seconds, including fractional seconds.
confirmed_atnumber or nullSame 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

StatusCause
400The 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.
401Missing or expired wallet JWT.
403The token is missing write:wallets, or read:wallets on the list endpoint.
404No account for that counterparty, or no relationship between you and that counterparty on that side.
409The relationship changed while the call was in flight. Read it back and retry.
429Rate 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