Structured Note Reference Data: API
The reference data feed is also available as an HTTP API. You give us an ISIN or a PDF, we give you the same nested record that ships in the daily file feed — every field on the term sheet, every underlier, every payoff breakpoint.
The API lives at https://api.sqxnotes.com/v1/. Authentication is by API key in the x-api-key header. We'll get you a key.
The five-second version
If you have an ISIN, ask for it:
curl -H "x-api-key: $API_KEY" \
"https://api.sqxnotes.com/v1/reference/byIsin?isin=US78017UPK06"
If you have a PDF, send it:
curl -H "x-api-key: $API_KEY" \
-H "Content-Type: application/pdf" \
--data-binary @./pricing_supplement.pdf \
https://api.sqxnotes.com/v1/reference/byPdf
Either way, when we already have the analyzed record, you get back 200 with the full payload right away. Skip the next section.
When we don't have it yet
Sometimes you'll ask about an ISIN we know exists — say, one that just hit our SEC ingest queue — but our extraction pipeline hasn't run on it yet. Or you'll send us a PDF whose ISIN we've never seen. In both cases we can't answer immediately. The note has to be analyzed end-to-end: text extracted, ISIN resolved, underliers identified, payoff structure decoded. That takes a few minutes per filing.
So the API uses the standard asynchronous request-reply pattern. Instead of making you wait on the connection, we accept the request, kick off the analysis on our side, and hand you back a job handle to poll.
You get a 202 Accepted response with three things in it:
{
"job_id": "df8414cbddf37d265ecc6e5961b8d9847d9cb9ddeeb06656848346ce6fe2e61d",
"status": "queued",
"status_url": "https://api.sqxnotes.com/v1/reference/jobs/df8414cbddf37d265ecc6e5961b8d9847d9cb9ddeeb06656848346ce6fe2e61d"
}
A Location header on the same response repeats the status_url, in case your HTTP client prefers to read it from there.
You then GET the status_url on a polling loop. While we're working, it returns 202 with {"status": "queued"} (the upload is in our queue but hasn't started analysis yet) or {"status": "processing"} (extraction is running). When the analysis completes, the same URL flips to 200 with the same nested record you would have gotten from byIsin directly — or to 404 if the filing was analyzed but turned out to hold no structured note record. Keep polling while you get a 202; stop on anything else.
A reasonable poll loop:
deadline=$(( $(date +%s) + 600 ))
while [ "$(date +%s)" -lt "$deadline" ]; do
code=$(curl -s -w '%{http_code}' -o /tmp/r.json \
-H "x-api-key: $API_KEY" "$status_url")
case "$code" in
200) cat /tmp/r.json; break ;; # done — payload is in /tmp/r.json
202) sleep 15 ;; # still working
4*|5*) echo "error: $code"; cat /tmp/r.json; break ;;
esac
done
Analysis runs on a separate pipeline from the API, so polling harder doesn't make it finish sooner: 15 seconds between polls is plenty. Bound the loop rather than waiting indefinitely — if something has gone wrong on our side, a loop that gives up and reports is more useful to you than one that spins.
Total wait is usually under five minutes; complex multi-tranche supplements can run longer.
The same status_url works for both flows — the only difference is the job_id shape (a 64-character SHA for PDF uploads, a numeric filing ID for ISIN lookups). You don't have to handle them differently.
What you get back on success
The 200 payload is the full nested reference-data record — same structure as the file feed. Top-level keys, in the order they appear:
call_leg issuer-call / autocall provisions
classification payoff archetype, link type, tax + legal
coupon_leg coupon mechanics, conditionality, memory, payout
fee_economics public-offering price, fees, estimated value
isin the 12-char identifier
mechanics issue date, denomination, listing, settlement
observation_leg observation schedule and reference levels
payoff_legs piecewise-linear payoff breakpoints at maturity
r_observation_schedule per-date observation calendar (incl. per-date coupon rate)
reference cross-references (filing_id, parent prospectus, etc.)
security_master identifying fields (CUSIP, issuer, currency, maturity)
underliers one entry per underlying asset (single-stock or basket)
Here is an excerpt of the response for a real autocallable note (US05617VCX10):
[
{
"isin": "US05617VCX10",
"classification": {
"instrument_id": 3937870,
"legal_wrapper": "NOTE",
"payoff_link_type": "EQUITY",
"payoff_profile": "AUTOCALLABLE",
"tax_classification": "CPDI",
"income_accrual_method": "CONSTANT_YIELD",
"has_phantom_income": "TRUE"
},
"call_leg": {
"instrument_id": 3937870,
"call_exercise_style": "AUTOCALL",
"call_payment_type": "PRINCIPAL_PLUS_COUPON",
"first_call_date": "2026-08-21",
"last_call_date": "2028-11-21"
},
"coupon_leg": {
"instrument_id": 3937870,
"coupon_rate_type": "FIXED",
"coupon_conditionality": "CONTINGENT",
"frequency": "QUARTERLY",
"has_memory": "TRUE",
"observation_scope": "WORST_OF",
"barrier": { "barrier_type": "PCT_OF_INITIAL" },
"payout": { "coupon_rate_pct": 7.35 },
"coupon_terms": "7.350% per annum (1.8375% per quarter); paid if Closing Level of each Underlying Asset >= 70% of Initial Level..."
},
"fee_economics": {
"estimated_value_pct": 96.424,
"public_offering_price_pct": 100.0,
"principal_amount_per_unit": 1000.0,
"total_offering_amount": 5140000,
"units_issued": 5140
},
"underliers": [
{ "underlier_id": "...", "weight": "...", "ticker": "..." },
{ "underlier_id": "...", "weight": "...", "ticker": "..." }
],
"...": "remaining sections: mechanics, observation_leg, payoff_legs, r_observation_schedule, reference, security_master"
}
]
coupon_leg.coupon_rate_pct is the first-period headline rate — derived from the earliest coupon row of the observation schedule. The per-date rates (step-up/step-down ladders carry one row per coupon period) are in r_observation_schedule[].coupon_rate_pct.
The full record for this ISIN is browsable at https://sqxnotes.com/api/reference/US05617VCX10.json — that's the same JSON shape the live API returns to a 200. Field-by-field documentation lives in the Data Dictionary.
The response is a JSON array. For most ISINs there's exactly one element; the array form is preserved because a single filing can cover multiple note tranches that share a parent prospectus.
Alongside the record we return two provenance fields:
lifecycle_status—active,inactive, ornull(matured vs. recently-priced).filing_status—preliminaryorfinal: whether the terms were extracted from a preliminary or a final pricing supplement. Preliminary terms can still change; the final supplement supersedes them.
The authoritative, field-by-field contract — every key, enum, and status code, including the response envelope — is the OpenAPI 3.1 spec (openapi.yaml). This page is the narrative guide; the spec is the source of truth.
Reading the payoff curve
payoff_legs is the note's cash settlement at maturity, drawn as a piecewise-linear function of where the underlier ends up. It is the machine-readable form of the "Payment at Maturity" paragraph on the term sheet, and for most notes it is the only place the downside protection is expressed — so it is worth getting the reading rules exactly right.
Each leg is one breakpoint on that curve, not one segment:
"payoff_legs": [
{ "piece_id": 84177, "underlier_pct_of_initial": 0.000, "payoff_pct_of_notional": 100.000 },
{ "piece_id": 84178, "underlier_pct_of_initial": 100.000, "payoff_pct_of_notional": 100.000 },
{ "piece_id": 84179, "underlier_pct_of_initial": 110.500, "payoff_pct_of_notional": 110.500 },
{ "piece_id": 84180, "underlier_pct_of_initial": 999.999, "payoff_pct_of_notional": 110.500 }
]
Units
underlier_pct_of_initial— the final level of the underlier as a percentage of its initial fixing level, to three decimals.100= unchanged,70= down 30%,130= up 30%,0= worthless. For a basket or worst-of note this is the level of whichever underlier the note's own terms observe (seeobservation_leg.observation_scope), not an average of them.payoff_pct_of_notional— the cash you receive at maturity as a percentage of face, to three decimals.100= par,142= $1,420 on a $1,000 note,0= total loss.
Both axes are percentages, never levels or dollars. Neither axis carries a currency — settlement currency is on security_master.
The curve covers maturity only. Contingent coupons, memory coupons and autocall premia are not points on it; they live in coupon_leg, call_leg and r_observation_schedule. Where a note's terms fold a final premium into the maturity payment, that premium is inside the curve's payoff value because the term sheet states it that way.
piece_id is an internal row handle, not a curve index. It is reassigned whenever a note is re-extracted, and it does not order the curve — see below. If you need to key a leg, key it on the (underlier_pct_of_initial, payoff_pct_of_notional) pair, which is unique per note and is what the file feed uses.
Reading between two breakpoints
Sort the legs ascending on underlier_pct_of_initial. The response array is not sorted for you; the API emits legs in whatever order the store returns them.
Between two consecutive breakpoints the payoff is linear. For an underlier level x falling between (x1, y1) and (x2, y2):
payoff(x) = y1 + (y2 - y1) * (x - x1) / (x2 - x1)
Nothing else is implied. There is no curvature, no smoothing, and no optionality baked into a segment — if a note's real payoff between two levels is not a straight line, the extraction emits enough breakpoints to approximate it as one.
Worked example, using the curve above (US09711QTW32, a principal-protected note capped at 110.5%): at an underlier of 105% of initial, x sits between (100, 100) and (110.5, 110.5), so the payoff is 100 + (110.5 - 100) × (105 - 100) / (110.5 - 100) = 105.0 — a 1:1 participation. At an underlier of 50% the payoff is 100, because the segment from (0, 100) to (100, 100) is flat: this note returns principal in full no matter how far the underlier falls.
Cliffs
A payoff curve for a barrier or trigger note jumps. We express a jump as two breakpoints at effectively the same underlier level carrying different payoffs, in one of two shapes:
- repeated level — the same
underlier_pct_of_initialtwice, e.g.(70, 70)and(70, 100); - one-tick offset — two breakpoints 0.001 apart, e.g.
(70, 70)and(70.001, 100), or(99.999, 100)and(100, 135.75). 0.001 is the smallest step either axis can represent, so a segment that narrow is a vertical jump, not a very steep ramp.
Treat both shapes as the same thing: a discontinuity at that level. Do not interpolate across the pair — an interpolation over a 0.001-wide segment will hand you an implied slope in the thousands.
Two examples, both real:
| ISIN | legs | reading |
|---|---|---|
US05615HZW14 | (0,0) (70,70) (70,100) (100,100) (200,324.25) | 70% barrier. At or above 70%, par. Below 70%, 1:1 with the underlier down to zero. |
US09711KBA34 | (0,10) (90,100) (99.999,100) (100,135.75) (999.999,135.75) | 10% buffer to 90%, then flat par to 100%, then a jump to a fixed 135.75% cap for any gain at all — a digital upside. |
Row order does not tell you which side of the jump is which. Both orderings occur in the data and neither is authoritative. Resolve it from the values instead: the payoff that continues the segment below the level is the limit approaching from below, and the payoff that continues the segment above it is the value at and above the level. Where the note's own threshold language reads "at or above L" — which is how essentially every barrier and trigger is drafted — the payoff exactly at L is the higher branch.
The far end of the curve
underlier_pct_of_initial = 999.999 is a sentinel meaning "and beyond". It is not a forecast that an underlier can go up 900%; it is the largest value the field can hold, used to anchor the right-hand end of the curve. It only ever appears on the underlier axis.
The payoff_pct_of_notional on that final leg is an ordinary payoff value, and the slope of the segment leading into it is what continues indefinitely:
- flat final segment → the note is capped.
US09711QTW32above ends(110.5, 110.5) (999.999, 110.5): the payoff stops rising at 110.5% and stays there. That is a hard cap and you can read it as one. - rising final segment → the note is uncapped. Extend that slope. A note ending
(100, 100) (999.999, 1450)has 150% participation with no cap; at an underlier of 300% it pays100 + 1.5 × 200= 400.
If no leg carries 999.999, the curve is undefined above its last breakpoint. This is the case that most often catches people out. A curve that ends at (200, 250) is telling you the payoff at 200% of initial — it is not telling you the note is capped at 250%. It might be capped there, it might carry on at 150% participation, and the legs alone cannot distinguish the two. Read the last breakpoint as the end of what we assert, and fall back to classification.payoff_profile and the mechanics prose before assuming a cap.
Where the data does not yet follow these rules
The rules above describe the convention. The stored data does not uniformly follow it yet, and you should code defensively for three departures. Figures are as of August 2026, across the 23,307 notes that carry any legs at all.
| what you may see | notes | how to handle it |
|---|---|---|
final leg (999.999, <real payoff>) — the convention above | ~10,960 | as documented |
no 999.999 leg at all; curve stops at 200% (or occasionally 100%) | ~9,340 | the tail is undefined — do not infer a cap |
payoff_pct_of_notional = 999.999, with the underlier value back-solved, e.g. (699.999, 999.999) | ~1,530 | the payoff was clipped at the field's maximum, not capped at 999.999%. The final segment's slope is the real information: extend it. Being migrated to the convention above. Guard on 999.999 appearing in the payoff column once: on a few dozen curves it shows up at several breakpoints, and those are defective rather than clipped — treat their tail as unknown. |
final leg (999.999, 999.999) | ~1,420 | treat as no information about the upside. It sits exactly on the 1:1 line, so it is indistinguishable between a genuine uncapped 1:1 note and an upside we failed to encode. Reading it as 1:1 participation will understate the upside on any note that actually pays more. |
That last row is worth being blunt about, because the safe reading and the natural reading differ. US61780ECR62 ends (100, 100) (999.999, 999.999); its summary text says the note pays 111% of any gain. The curve says 1:1. The curve is the one that is wrong.
We are normalising all four shapes onto the first. Until that lands, a consumer who (a) sorts on the underlier, (b) refuses to interpolate across a sub-0.01-wide segment, (c) never reads a missing 999.999 leg as a cap, and (d) treats (999.999, 999.999) as unknown rather than as 1:1, gets a correct answer on every note in the set or a clean "unknown" — never a wrong number.
Keeping up with the final terms
When you first ask about a note that's only just been filed, the record you get may carry filing_status: "preliminary". Issuers later file a final pricing supplement with the locked terms.
You don't have to poll for that yourself. On any byIsin call, if we've ingested a newer filing for the ISIN than the one behind the record on file — the final supplement, or an amendment — we re-extract from it automatically and answer 202 with a poll handle (see "When we don't have it yet"), rather than handing you stale preliminary terms. Once the newer extraction lands, the same lookup returns 200 with filing_status: "final". In practice: an ISIN we'd previously answered 200 for can briefly return 202 again while the fresher terms are extracted — that's the system keeping you current, not an error.
When the ISIN isn't a structured note
If you call /reference/byIsin with an ISIN we have on file as something other than a structured note — an ETF, an ADR, a corporate bond — we'll still answer. You get 200 with whatever we have in our security master:
{
"isin": "US0010122028",
"cusip": "001012202",
"description": "AECI LTD",
"type": "ADR",
"issuer": "AECI Ltd",
"message": "ISIN is type=ADR; only security_master fields are available for this type."
}
If we've never heard of the ISIN at all, you get 404.
Finding out what we have, before you spend anything
An ISIN we hold in the security master but have no offering document for comes back as a stub — reference_scope: "security_master_only", no terms, no underliers, no payoff curve. That answer is free. A sparse return costs nothing against your distinct-ISIN limit; you are never charged to discover a gap.
To ask ahead of time, POST a list to /reference/coverage:
curl -H "x-api-key: $API_KEY" -H "Content-Type: application/json" \
-d '{"isin_list": ["US05613FTH72", "US83371HCZ08"]}' \
https://api.sqxnotes.com/v1/reference/coverage
It sorts them into covered (terms are extracted; a lookup returns them now), extractable (we have identified the offering document but haven't extracted it yet — a lookup returns 202 and the terms follow a few minutes later), and not_covered (we hold the security and no document). This call is free and unmetered too.
What we cover, by issuer
Structured notes we hold in the security master, and what you get back when you ask for one.
Terms ready — the payoff curve, underliers and coupon mechanics are extracted and /reference/byIsin returns them immediately.
On request — we have identified the offering document but have not extracted it yet. Ask for the ISIN and we start the extraction and hand you a poll URL; the terms arrive a few minutes later. Nothing needs to be arranged in advance.
The remainder is the security only, with no document behind it. Those come back as reference_scope: security_master_only — and that answer is free: it costs nothing against your distinct-ISIN limit. To check a specific list before you request it, POST it to /v1/reference/coverage, also free.
| Issuer | Notes we hold | Terms ready | On request | Reachable |
|---|---|---|---|---|
| JPM | 80,308 | 3,725 | 49,346 | 66.1% |
| GS | 38,574 | 2,998 | 27,154 | 78.2% |
| MS | 40,383 | 2,667 | 29,746 | 80.3% |
| Barclays | 29,472 | 2,498 | 20,447 | 77.9% |
| Citi | 50,988 | 2,225 | 29,294 | 61.8% |
| UBS | 39,662 | 2,211 | 34,846 | 93.4% |
| BOFA | 23,922 | 1,891 | 16,402 | 76.5% |
| BMO | 16,514 | 1,302 | 12,671 | 84.6% |
| HSBC | 17,333 | 764 | 9,060 | 56.7% |
| BNS | 13,170 | 641 | 3,608 | 32.3% |
| RBC | 12,217 | 630 | 9,512 | 83.0% |
| TD | 12,290 | 621 | 10,193 | 88.0% |
| BNP | 51,314 | 143 | 15,271 | 30.0% |
| SG | 10,008 | 10 | 107 | 1.2% |
| BBVA | 14,787 | 2 | 2 | 0.0% |
| CS | 11,169 | 0 | 6,570 | 58.8% |
Issuers with fewer than 5,000 notes are omitted. Coverage is document-derived: we extract terms from the offering document, so an issuer whose US notes are not registered with the SEC reads low here and no amount of work on our SEC pipeline will change that — the only route to those terms is a distributor feed.
_Generated from prod on 2026-08-14 by sn_coverage_by_issuer.sh. Do not edit by hand._
When the PDF isn't what you thought
PDFs without a parseable ISIN are still accepted — our pipeline mints a synthetic identifier and runs the same extraction. If the document turns out to be an SN filing whose ISIN was on a page our OCR couldn't read, you'll still get a result. If it isn't an SN at all, the analysis pipeline detects that and skips the record cleanly; the polling response will eventually surface a 404 or a status indicating no record was produced.
PDFs containing active content — embedded JavaScript, launch actions, embedded files, XFA forms — are refused with 400 at the upload boundary. We don't try to strip the dangerous bits and continue; we reject the upload outright. Extracted text is also normalized and prompt-injection markers neutralized before any downstream LLM step.
The hard size cap is 10 MB per request. Above that you'll get a 413, returned as plain text by the AWS platform layer (this is the only response that isn't JSON).
When you get input wrong
Bad input returns 400 with a structured error in the errors array, in the same ErrorDetailV2 shape the rest of the v1 API uses — a numeric messageCode, a category, a machine-readable type, and a human message:
{
"data": null,
"errors": [
{
"category": "VALIDATION_ERROR",
"type": "INVALID_INPUT",
"messageCode": 1010,
"message": "Invalid ISIN: checksum invalid",
"messageValues": { "isin": "US06370EDU90" }
}
],
"meta": { "...": "..." }
}
Filter on messageCode, not on message text — the codes are stable, the wording isn't. The platform-wide codes:
| messageCode | When |
|---|---|
1001 | A required parameter is missing. messageValues.parameter names it. |
1010 | ISIN is malformed or fails its check digit; also a filter the route will not apply. |
1011 | CUSIP is malformed or fails its check digit. |
1012 | A date isn't a valid YYYY-MM-DD, or a window's to is before its from. |
1015 | The request names something this route does not offer — a field, a product, a job kind — or asks for intraday prices on a security that isn't a treasury. messageValues names it. |
1033 | The API key's grant has lapsed (403). |
1040 | The identifier is well-formed but we hold no such security or job (404). |
1050 | The key is over a distinct-ISIN quota (lifetime or monthly), over its per-second rate, or a bulk request exceeded its cap (429). |
1060 | A database failure on our side, or an asynchronous job that failed or could not start (500 / 503). |
Lowercase identifiers are silently uppercased — copy-paste from a spreadsheet just works.
The table above is platform-wide; no endpoint in this family takes a CUSIP or a date, so 1011 and 1012 never appear here.
This endpoint family adds two of its own input surfaces, in the same envelope. PDF uploads reject an empty body, anything without a %PDF- header, and files carrying active-content markers such as /JavaScript. The status URL rejects a missing job_id, one that is neither a numeric filing id nor a 64-character SHA, and one we hold no job for — that last is a 1040, alongside a next field telling you how to submit the document.
One caveat on those two surfaces: their 400s currently come back with messageCode 1090 — the platform's generic catch-all, with category: "OTHER" — rather than a dedicated validation code. Until they migrate, treat a 1090 on an upload or a status poll as bad input and read the message text for the specifics; it's the only discriminator these responses carry.
All API responses are JSON, with a single platform-level exception: the 413 for oversize bodies, which is plain text from the AWS gateway.
Endpoint reference
| Method | Path | Purpose |
|---|---|---|
| GET | /reference/byIsin?isin={ISIN} | Look up by ISIN. 200 if analyzed (with filing_status), 202 if known but pending or a newer filing supersedes the record on file, 404 if unknown. |
| POST | /reference/byPdf | Upload a PDF. 200 if its ISIN is already analyzed, 202 with a job_id otherwise, 400 on bad input. |
| GET | /reference/jobs/{job_id} | Poll a 202 from either of the above. 200 with payload when ready, 202 with status while pending. |
| POST | /reference/coverage | Ask which of a list of ISINs we hold terms for. Free — never metered, never counted against your ISIN limit. |
| GET | /version | Report API version. No auth required. |
Authentication & versioning
Every request except /version needs a valid API key in the x-api-key header. Keys are scoped per client, and a key can also carry an expiry, a lifetime distinct-ISIN quota — a cap on how many distinct ISINs it will ever be served — a monthly distinct-ISIN cap per product (or shared across a chosen set of products), and a per-second request rate. A key whose grant has lapsed answers 403 (messageCode 1033); one over a quota, a monthly cap or its rate answers 429 (messageCode 1050, with the limit and window in messageValues).
The v1 base path is stable. Field additions are non-breaking and ship in-place. Breaking changes — renames, removals, semantic shifts — land at a new major version on a new stage. Minor and patch updates are reported at /version.
On this endpoint family, the scope also selects which reference fields a key is served — two keys can get different column sets back for the same ISIN.