When to Use
Invoke this skill when building a trading bot for E*TRADE. Unlike most modern broker APIs
that use OAuth 2.0 bearer tokens, E*TRADE uses OAuth 1.0a with HMAC-SHA1 request
signing. Every API request must carry an Authorization: OAuth ... header with a nonce,
timestamp, and signature computed over a canonical signature base string. This skill
covers the full three-legged flow, per-request signing, and E*TRADE's two distinct token
lifetime rules.
When NOT to Use
- For any other broker. OAuth 1.0a request signing is specific to E*TRADE among the
brokers covered here. Schwab uses OAuth 2.0 PKCE (
schwab-api-oauth-pkce-flow), Upstox uses refresh-token rotation (upstox-oauth-refresh-token-rotation). - As a general OAuth 1.0a library. The signing core here is RFC 5849-conformant, but
RSA-SHA1andPLAINTEXTare not implemented — E*TRADE documentsHMAC-SHA1as the only supportedoauth_signature_method. - As an HTTP client.
scripts/etrade_auth.pydeliberately issues no requests and adds no dependencies. It returns URLs and header values; you supply the transport, timeouts, and retry policy. - As a headless/unattended login. Leg two requires a human to approve in a browser and
copy back a verifier code. Automating a daily unattended restart is a separate problem —
see
headless-broker-auth-patternsandtoken-lifecycle-live-probing. - As secret storage. This holds credentials in memory only. Persistence, rotation, and
vaulting belong to
centralized-secrets-management-vault-integrationandsecrets-rotation-without-bot-downtime.
Prerequisites
- E*TRADE developer account with a consumer key and consumer secret. Sandbox and production issue separate key pairs.
- A browser-capable human for leg two of the flow, once per calendar day.
- Host clock synchronized to within 5 minutes of E*TRADE's clock (
oauth_timestamptolerance). Seeclock-drift-monitoring-alerting-thresholds.
Workflow
-
Request Token —
GET {base_url}/oauth/request_token.- All four token endpoints are GET, not POST.
oauth_callbackis mandatory and must be"oob", whether or not the app has a callback URL registered. It has no server-side default; omitting it fails the call.build_request_token_header()sets it.- Sign with the consumer secret and an empty token secret — the signing key still
ends in
&. - Parse the form-encoded response with
parse_token_response(). An E*TRADE error body also parses cleanly as form data, so decision point: reject any response without bothoauth_tokenandoauth_token_secretrather than storing empty strings — an empty-credential client fails opaquely at the first API call, not here.
-
User Authorization —
GET https://us.etrade.com/e/t/etws/authorize?key=…&token=….- Decision point — this is a different host. The authorize page is on
us.etrade.comfor both sandbox and production. Deriving it fromapi.etrade.comorapisb.etrade.comyields a dead URL. - Percent-encode both query values. Request tokens routinely contain
+,/, and=; an unencoded+arrives at the server as a space and authorizes nothing. - The user approves and receives an
oauth_verifieron the Authorization Complete page (or appended to a registered callback URL). - The request token is valid for 5 minutes. If the user is slower, restart at leg one — do not retry the exchange with the stale token.
- Decision point — this is a different host. The authorize page is on
-
Access Token —
GET {base_url}/oauth/access_token.- Sign with the consumer secret and the request token secret, including
oauth_token(the request token) andoauth_verifier.
- Sign with the consumer secret and the request token secret, including
-
Sign Requests — build the RFC 5849 base string, then HMAC-SHA1 it.
- Base string URI (§3.4.1.2): lowercase scheme and host, drop the default port, and exclude the query and fragment.
- Parameters (§3.4.1.3.1): the
oauth_*header parameters plus the URL's query parameters plus any form-encoded body parameters.oauth_signatureandrealmare excluded. - Normalization (§3.4.1.3.2): percent-encode first, then sort by encoded name and, for repeated names, by encoded value.
- Signing key:
percent_encode(consumer_secret) + "&" + percent_encode(token_secret). - Decision point — sign the exact URL you will send. Signing a bare path and then
appending
?detailFlag=ALLproduces a signature E*TRADE rejects, because the query parameters were never in the base string.
-
Token Lifecycle — two independent rules, with different remedies.
- Idle inactivation: after 2 hours with no API request the access token is
inactivated. Remedy:
GET {base_url}/oauth/renew_access_token(sign_renew_access_token()). - Daily expiry: the token expires at the end of the current calendar day, US Eastern. Remedy: the full three-legged flow again, with a human. Renewal does not extend past midnight ET.
- Decision point — classify the failure before reacting. A 401 after a quiet period is an idle inactivation and needs a renew; a 401 after midnight ET needs re-authorization. Looping renew calls against an expired token never recovers.
- Revoke on shutdown or suspected compromise:
GET {base_url}/oauth/revoke_access_token(sign_revoke_access_token()).
- Idle inactivation: after 2 hours with no API request the access token is
inactivated. Remedy:
Full procedure: see
references/workflows.md. Standards and endpoint table: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Omitting query parameters from the signature base string. The single most common
cause of a persistent 401 on E*TRADE:
/v1/accounts/{id}/orderssigns fine, then?status=OPEN&count=50is appended and every call is rejected. Query parameters are a mandatory signature input (RFC 5849 §3.4.1.3.1), and the base string URI must carry no query at all. - Sorting parameters before percent-encoding. Raw
"a b" < "a+"(0x20 < 0x2B), but encoded"a%20b" < "a%2B"orders them the other way. The difference only surfaces on values containing spaces,+, or non-ASCII — so it passes every simple test and fails on the first symbol list or free-text order field. - Omitting
oauth_callbackon leg one. E*TRADE requires it on the request-token call and documents that it must always be"oob". There is no default. - Building the authorize URL from the API host. The authorize page is on
us.etrade.com, notapi.etrade.com/apisb.etrade.com, in both environments. - Not percent-encoding the request token in the authorize URL. Base64-ish tokens
contain
+,/, and=; an unencoded+is decoded server-side as a space. - Not percent-encoding the signature in the header. Base64 output contains
+,/, and=, all of which must be escaped inside the quoted header value. - Renewing "before market open" and assuming you are covered. The 2-hour idle clock is independent of the trading session. A strategy that places one order at the open and then goes quiet is inactivated by 11:30 ET even though the token has not expired.
- Retrying a renew against a token that expired at midnight ET. Renewal only reverses idle inactivation. Past end-of-day US Eastern the token is gone and needs a human.
- Reusing a nonce with the same timestamp. E*TRADE rejects it. Generate the nonce and timestamp fresh per request; never cache a signed header for reuse.
- Clock skew.
oauth_timestampmust be within 5 minutes of E*TRADE's clock. A drifting container clock produces signature rejections that look like credential failures. - Logging credential objects. A default dataclass
reprprints secrets in plain text into logs and tracebacks.OAuth1CredentialsandOAuth1Tokenmark secret fieldsrepr=False; keep it that way.
Verification
- Reproduce the RFC 5849 §3.4.1.1 published base string with
build_base_string("POST", "http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b", …)including the body parametersc2=anda3=2 qand arealmthat must be dropped. This vector exercises query-parameter inclusion, a repeated name,realmexclusion, and encode-then-sort ordering at once. - Reproduce the X (Twitter) OAuth 1.0a documented signature
Ls93hJiZbQ3akF3HF3x1Bz8/zU4=from its published base string and signing key, which pins HMAC-SHA1 and base64 end to end. - Confirm
build_request_token_header()emitsoauth_callback="oob"and nooauth_token. - Confirm
get_authorize_url()starts withhttps://us.etrade.com/e/t/etws/authorize?for bothuse_sandbox=TrueandFalse, and that a tokena+b/c=encodes toa%2Bb%2Fc%3D. - Negative checks:
parse_token_response()must raise on an empty body, onoauth_problem=signature_invalid, and on a response missing either token field;sign_request()andsign_renew_access_token()must raise before an access token is set; blank consumer credentials must raise at construction. - Confirm
repr(OAuth1Credentials(...))contains neither secret. - Run
python -m unittest discover -s skills/etrade-oauth1-signature-flow/scriptsand confirm 100% pass rate.