POST Login
Single-call credentials-mode login for catalogs that require a username/password
https://api.openwire.sh/v1/wire/loginSign in to a catalog that requires credentials, and get back a credential_id you can immediately use with POST /v1/wire/task.
The endpoint:
- Resolves the catalog by slug
- Finds-or-creates an identity for you (auto-named from
paramsif you don't supplyidentity_name) - Runs the catalog's login wheel against the supplied params
- Persists a cookies-only credential (the password is never stored)
- Returns everything needed to call authenticated actions
The password is discarded after a successful sign-in. Only the issued cookies are stored, encrypted. When the cookies expire (see
expires_at), call/loginagain to mint fresh ones.
For browser-based catalogs (auth_types includes browser_state but not credentials), use the interactive connect flow instead.
Request Body
{
"catalog_slug": "neb",
"identity_name": "work-account",
"params": {
"email": "alice@example.com",
"password": "..."
}
}| Parameter | Type | Description |
|---|---|---|
catalog_slug required | string | Catalog slug, e.g. neb. Find it in GET /v1/wire/catalog. |
identity_name | string | Optional label for the identity. If omitted, derived from params (priority: email > username > user > login > account_id > account, falling back to "default"). Same name re-uses the same identity row across calls. |
params required | object | Wheel-defined login fields. Shape varies per catalog — discover it by reading login_input_schema from GET /v1/wire/catalog/{slug}. |
Discovering the params shape
Different catalogs require different fields. Always read the schema first:
curl {API_BASE}/catalog/neb \
-H "X-API-Key: your_api_key"The response includes login_input_schema — an array of {name, type, required, description} field descriptors. Send exactly those fields in params.
Response
201 Created{
"status": "verified",
"identity_id": "7c3f1a2b-4d5e-6f7a-8b9c-0d1e2f3a4b5c",
"identity_name": "alice@example.com",
"credential_id": "11111111-2222-3333-4444-555555555555",
"catalog_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"catalog_slug": "neb",
"expires_at": "2026-05-29T08:09:23Z"
}| Field | Type | Description |
|---|---|---|
status | string | Always "verified" on success |
identity_id | string (UUID) | The identity this credential is attached to. Stable across re-runs with the same name. |
identity_name | string | The name used (explicit or derived) |
credential_id | string (UUID) | Pass this as credential_id in subsequent POST /v1/wire/task calls. |
catalog_id | string (UUID) | The catalog's UUID — saves you another lookup |
catalog_slug | string | Echoed back |
expires_at | string | RFC3339 UTC. When the session expires. Capped at 30 days; defaults to 24h if the wheel doesn't supply one. Re-call /login to refresh. |
Idempotent re-runs
Calling /login again with the same identity_name (or the same derived name) re-uses the existing identity row and refreshes its credential in place — no duplicate rows accumulate. Pass an explicit identity_name if you want multiple identities for the same catalog under one account (e.g. work + personal).
Error Responses
All errors return JSON of the form { "status": "error", "error": { "code": "...", "message": "...", "missing_fields"?: [...] } }. The wheel's raw error text is never echoed; only the typed code is surfaced.
| Code | HTTP | When |
|---|---|---|
INVALID_BODY | 400 | Request body isn't valid JSON |
INVALID_INPUT | 400 | catalog_slug or params missing |
INVALID_PARAMS | 400 | One or more required: true fields from login_input_schema are missing, null, or blank. Response includes missing_fields: [...]. The wheel is not invoked — failures here cost nothing. |
CATALOG_NOT_FOUND | 400 | No catalog matches catalog_slug |
LOGIN_NOT_SUPPORTED | 400 | The catalog's auth_types doesn't include credentials, OR the catalog has no published login action |
LOGIN_NOT_AVAILABLE | 503 | Credentials-mode login isn't configured on this engine (rare; infrastructure issue) |
LOGIN_TRANSPORT_ERROR | 502 | Could not reach the wheel runner |
BAD_PASSWORD | 200 | Sign-in rejected by the site — wrong credentials |
MFA_REQUIRED | 200 | Account requires multi-factor auth (not yet supported) |
CAPTCHA_REQUIRED | 200 | Site is showing a captcha challenge |
ACCOUNT_LOCKED | 200 | Site reports the account is locked |
LOGIN_TIMEOUT | 200 | Sign-in took too long |
LOGIN_PAGE_CHANGED | 200 | Site's login flow may have changed |
LOGIN_NO_COOKIES | 200 | Sign-in completed but no session cookie was issued |
LOGIN_INFRASTRUCTURE_ERROR | 200 | Transient Browser Connect / upstream issue. Retry. |
LOGIN_FAILED | 200 | Generic fallback — sign-in failed for an unclassified reason |
HTTP 200 with
status: "error"is used for "the request was well-formed and the wheel ran, but the sign-in itself didn't complete." Treat anystatus != "verified"as failure regardless of HTTP code.
Example INVALID_PARAMS response:
{
"status": "error",
"error": {
"code": "INVALID_PARAMS",
"message": "Missing required login fields: password",
"missing_fields": ["password"]
}
}Code Examples
curl {API_BASE}/login \
-X POST \
-H "X-API-Key: your_api_key" \
-H "Content-Type: application/json" \
-d '{
"catalog_slug": "neb",
"params": {
"email": "alice@example.com",
"password": "your_password"
}
}'import requests
response = requests.post(
'{API_BASE}/login',
headers={'X-API-Key': 'your_api_key'},
json={
'catalog_slug': 'neb',
'params': {
'email': 'alice@example.com',
'password': 'your_password',
},
},
)
data = response.json()
if data['status'] == 'verified':
credential_id = data['credential_id']
# Use credential_id with /v1/wire/task
else:
print(f"Login failed: {data['error']['code']} — {data['error']['message']}")
if data['error']['code'] == 'INVALID_PARAMS':
print(f"Missing: {data['error']['missing_fields']}")const response = await fetch('{API_BASE}/login', {
method: 'POST',
headers: {
'X-API-Key': 'your_api_key',
'Content-Type': 'application/json',
},
body: JSON.stringify({
catalog_slug: 'neb',
params: {
email: 'alice@example.com',
password: 'your_password',
},
}),
});
const data = await response.json();
if (data.status === 'verified') {
const credentialId = data.credential_id;
// Use credentialId with /v1/wire/task
} else {
console.error(`Login failed: ${data.error.code} — ${data.error.message}`);
if (data.error.code === 'INVALID_PARAMS') {
console.error('Missing:', data.error.missing_fields);
}
}Rate limit
10 requests per minute per user (each call triggers a real sign-in run against the target site).
Related
- GET /v1/wire/catalog/{slug} — discover
login_input_schema - GET /v1/wire/identities — list identities + credentials
- POST /v1/wire/task — use the returned
credential_idto run an authenticated action