Fathom API
Everything the Fathom web app does, it does through this API. Create samples, upload reads, run analyses, and read results and artifacts from your own scripts and pipelines.
| Base URL | https://app.fathomgenomics.com — every path below starts with /api/ |
|---|---|
| Format | JSON request bodies (Content-Type: application/json) and JSON responses. File bytes go to pre-signed S3 URLs, never through the API itself. |
| Authentication | Bearer token from sign in, valid 30 days. Endpoints marked 🔒 require it. |
| Billing | Analyses cost credits; the price is quoted by the server before you commit, and a failed analysis is refunded. See Pricing & credits. |
| Support | support@fathom-genomics.com |
Authentication
Every call carries a bearer token in the Authorization header. There are two kinds:
API tokens (recommended)
In the app, open your account from the header (your email address) and create a token under API tokens. Give it a name and an expiry (30, 90 or 365 days, or none). The token starts with fthm_ and is shown once; copy it then. It works however you sign in to the app, including with Google or Microsoft.
TOKEN=fthm_... # from Account → API tokens
curl -s https://app.fathomgenomics.com/api/account \
-H "Authorization: Bearer $TOKEN"
- A token has the reach of your account, except that it cannot create, list or revoke tokens, link or unlink sign-in methods, connect or disconnect Box or S3, or buy credits. Those calls answer
403 {"code":"session_required"}; do them in the app. - Revoke a token in the same place at any time; the next call with it fails immediately. The account page shows when each token was last used.
- Keep tokens secret: one lets its holder read everything in your account, upload reads and spend your credits on analyses. Fathom stores only a hash of it and can never show it again.
Session tokens
Accounts with a password can also sign in from a script. The answer is the same 30-day session token the web app uses; sign in again to get a new one (there is no refresh call).
curl -s https://app.fathomgenomics.com/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"you@example.com","password":"your-password"}'
# → {"token":"eyJhbGciOi...","user":{"user_id":"...","email":"...","credit_balance":2,...}}
With either kind, a missing or malformed header answers 401 {"error":"Unauthorized"}; an expired token 401 {"error":"Token expired"}; anything else invalid 401 {"error":"Invalid token"}.
Quickstart: upload paired reads and run a human pathogen analysis
The whole flow with curl and jq. Each step is documented in full further down.
API=https://app.fathomgenomics.com/api
# Your API token, from Account → API tokens in the app (or a session token from /api/auth/login)
TOKEN=fthm_...
AUTH="Authorization: Bearer $TOKEN"
# Create a sample (accession is your own identifier, unique within your account)
SAMPLE=$(curl -s $API/samples -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"accession":"RUN-2026-001","description":"nasopharyngeal swab"}' | jq -r .sample.sample_id)
# Upload a read file: presign → PUT the bytes → finalize. Repeat with asset_type fastq_r2 for R2.
FILE=reads_R1.fastq.gz
SIZE=$(stat -c%s $FILE 2>/dev/null || stat -f%z $FILE)
PRESIGN=$(curl -s $API/samples/$SAMPLE/assets/presign -H "$AUTH" -H 'Content-Type: application/json' \
-d "{\"filename\":\"$FILE\",\"asset_type\":\"fastq_r1\",\"size_bytes\":$SIZE}")
ASSET=$(echo "$PRESIGN" | jq -r .asset.asset_id)
curl -s -X PUT "$(echo "$PRESIGN" | jq -r .upload.url)" \
-H 'Content-Type: application/octet-stream' --data-binary @$FILE
curl -s -X POST $API/samples/$SAMPLE/assets/$ASSET/finalize -H "$AUTH"
# Ask the price (dry_run), then submit with that price as expected_credits
BODY='{"analysis_type":"human_pathogens","input_refs":{"fastq_asset_ids":["'$ASSET'"]},"parameters":{}}'
CREDITS=$(curl -s $API/samples/$SAMPLE/analyses -H "$AUTH" -H 'Content-Type: application/json' \
-d "$(jq -c '. + {dry_run:true}' <<<"$BODY")" | jq -r .credits)
ANALYSIS=$(curl -s $API/samples/$SAMPLE/analyses -H "$AUTH" -H 'Content-Type: application/json' \
-d "$(jq -c --argjson c $CREDITS '. + {expected_credits:$c}' <<<"$BODY")" | jq -r .analysis_id)
# Poll until status is complete (or failed), then read the result
curl -s $API/samples/$SAMPLE/analyses/$ANALYSIS -H "$AUTH" | jq '.analysis.status'
curl -s $API/samples/$SAMPLE/analyses/$ANALYSIS -H "$AUTH" | jq '.result | keys'
# Download an artifact (the answer is a 15-minute pre-signed URL)
curl -s $API/samples/$SAMPLE/analyses/$ANALYSIS/artifacts/bacteria_classification.json -H "$AUTH" | jq -r .url | xargs curl -sO -J
Errors & codes
Every error is JSON with a human-readable error. Many also carry a stable code to branch on, and validation errors name the offending field.
{"error": "This analysis costs 2 credits. Press Run again to confirm.", "code": "quote_required", "credits": 2, "quote": {...}}
| Status | Meaning |
|---|---|
400 | Malformed request: a missing or invalid field (see field), an unsupported analysis type, a wrong file extension, a file too large for a single PUT (file_too_large). |
401 | No, expired or invalid token. |
402 | insufficient_credits: the analysis costs more than your balance (required, balance). |
403 | session_required: an API token was used on a call only a signed-in session may make (managing tokens, sign-in methods, Box/S3 connections, purchases). Also registration closed, or a dev-only endpoint in production. |
404 | Not yours or does not exist: samples, assets, analyses and artifacts you do not own all read as not found. |
409 | State conflict. quote_required and price_changed (resend with the quoted expected_credits), sample_changed (re-read and retry), file_changed (a read file changed after it was measured), slot_taken (that read slot already holds a file), upload_missing / upload_too_late, missing_r1, reads_expired, duplicate accession or project name. |
410 | artifact_expired: a read-level artifact whose reads have been deleted under the retention policy. |
422 | Admitted inputs only: the selection is larger than this analysis type can process today (see input_limits in config), or an SRA run the policy refuses. |
429 | Per-user SRA lookup limit. |
503 | busy (retry in a moment), a disabled feature (SRA, Stripe), or reference data that failed to load. Safe to retry. |
Service
Liveness. Answers {"status":"ok"}.
Non-secret runtime settings. Send your token to get the upload block as it applies to you.
Response
input_limits | Per analysis type, the largest input admitted today: max_bytes, max_bases, max_records under default and per-type overrides (vcf_kmer_support, protein_kmer_support). |
credit_pricing | How analyses are priced: base credits per type (default 1, human_pathogens 2) × bands of unit_bytes of gzipped reads; anything up to first_unit_bytes is one band. |
credit_purchase | unit_cents, volume tiers (min_credits, percent_off), max_credits per purchase, expiry_days, sales_email. |
upload | single_put_max_bytes (the presign path's per-file limit), multipart_enabled, multipart_threshold_bytes, window_hours (a multipart upload must finish inside it), largest_processable_bytes. |
read_retention_hours, read_max_lifetime_days, retention_mode | The read retention policy: uploads are deleted this many hours after the sample's last analysis (results are kept), and never kept past the lifetime cap. Enforced only when retention_mode is enforce. |
sra_enabled, stripe_enabled, stripe_publishable_key | Feature switches. |
Auth & account
Create an account. New accounts start with 2 credits, one human pathogen analysis of up to the first pricing band.
Body
name | string, required |
email | string, required; stored lower-cased |
password | string, required, at least 8 characters |
organization | string, optional |
Response 201
{"token": "...", "user": {user_id, email, name, organization, credit_balance, samples_processed}}. 409 when the email is taken, 403 when registration is closed.
Body {"email", "password"}. Answers {"token", "user"} as above, or 401 {"error":"Invalid email or password"}.
{"google": bool, "microsoft": bool, "registration": bool}: which single sign-on providers are enabled and whether self-service registration is open.
Your account.
Response
user_id, email, name, organization | Identity. |
credit_balance | Whole credits available now. |
samples_processed | Lifetime count of analyses run. |
has_password, identities | Whether a password is set, and the linked SSO identities (provider, email). |
credit_expiry_days, credit_next_expiry | Your credit expiry term and when the soonest unused credits expire (ISO timestamp or null). |
API tokens
All three need a session token (sign in with a password, or do it in the app); an API token answers 403 session_required.
{"tokens": [{token_id, name, prefix, created_at, expires_at, last_used_at}]}, newest first. The token value itself is never returned.
Body {"name", "expires_in_days": 30 | 90 | 365 | null} (default 365). 201 {"token": "fthm_...", "record": {...}}: the only time the token is shown. 409 for a duplicate name, 400 token_limit at 20 tokens.
Revokes the token immediately. {"deleted": true}; 404 if it is not yours.
Projects
A project is a label you can attach to samples and filter by. Names are unique per account (case-insensitive), at most 60 characters, at most 200 projects.
{"projects": [{"name", "created_at"}]}, sorted by name.
Body {"name"}. Answers 201 with the full list; 409 if the name exists.
Samples
A sample holds one set of reads (uploaded FASTQ or BAM files, or an NCBI SRA run) and any number of analyses over them.
The sample object
sample_id | UUID; the identifier used in every path. |
accession | Your identifier, unique within your account, up to 120 characters. |
description | Free text, up to 1000 characters. |
state | draft until a read file is ready, then ready; archived hides it from the default listing. |
project | Project name or absent. |
analysis_count, active_analysis_count | Analyses ever run, and those still pending or running. |
sra_run, sra_accession | Present on an SRA-backed sample: the run's accession, metadata, read class and the frozen billing size. |
reads_hold_until, reads_expired_at | Retention bookkeeping (see config). |
created_at, updated_at | ISO 8601 UTC. |
Body
accession | required |
description | optional |
sra_accession | optional: an SRA run (SRR…, ERR…, DRR…). The sample is created ready with no uploads; see NCBI SRA input. |
201 {"sample": {...}}. 409 when the accession exists; 400/422/503 for a refused SRA run.
Your samples, newest activity first, paged by cursor.
Query
limit | 1 to 150, default 50 |
cursor | the cursor from the previous page |
q | substring of accession, description or SRA accession |
project / no_project=true | one project, or samples with none |
from, to | YYYY-MM-DD, on updated_at |
include_archived=true | include archived samples |
{"samples": [...], "cursor": "..." | null, "total": n}. total is sent on the first page only.
{"sample", "assets": [...], "credit_quotes": {type: {"credits", "billable_bytes", "units"}}}. The quotes are what each analysis type would cost on the reads currently on the sample; a type reads {"blocked", "code"} instead when it cannot be quoted (for example file_changed).
Body with any of description, state (draft | ready | archived), project (an existing project name, or "" to clear). Answers {"sample"}; 400 for an unknown project; 409 sample_changed if the state moved underneath you.
Assets & uploads
An asset is one file attached to a sample. Bytes are uploaded straight to S3 through pre-signed URLs; the API only mints the URLs and records the result.
Asset types
| asset_type | What it is | Extensions |
|---|---|---|
fastq_r1, fastq_r2 | Reads: one single-end file, or an R1/R2 pair. Gzipped is strongly preferred (it is also what pricing measures). | .fastq, .fq, .fastq.gz, .fq.gz |
reads_bam | Reads as an aligned or unaligned BAM (the usual PacBio and ONT delivery). An alternative to FASTQs, not an addition. | .bam |
reference_fasta | Your reference for the custom and vcf_kmer_support analyses. | .fa, .fasta, .fna, each also .gz |
vcf | Variants for vcf_kmer_support. | .vcf, .vcf.gz, .bcf |
protein_variants_tsv, protein_reference_fasta | Inputs for protein_kmer_support. | .tsv; .faa, .fa, .fasta, each also .gz |
A sample holds at most one file per read slot (fastq_r1, fastq_r2, reads_bam); a second upload to an occupied slot is refused with 409 slot_taken. The first read file to become ready moves the sample to ready.
The asset object
asset_id, sample_id, asset_type, filename | Identity. |
state | uploading → ready, via completing for multipart; failed; expiring / expired once retention has deleted the reads. |
size_bytes, etag, finalized_at | Set when the upload is confirmed. |
source, upload_mode | upload, box or s3; multipart when it was. |
expires_at, cap_at, live | Retention: when the reads will be deleted (null while an analysis holds them), the hard lifetime cap, and whether the file is usable now. |
{"assets": [...]}, newest first.
Single-request upload (files up to upload.single_put_max_bytes, 5 GiB)
Body
filename | required; the extension must match the type |
asset_type | required |
size_bytes | required for read types, ignored otherwise. The exact byte count: it is signed into the URL. |
201 {"asset": {... "state":"uploading"}, "upload": {"url": "..."}}. The URL is valid for one hour.
Send the file body with Content-Type: application/octet-stream and, for reads, exactly size_bytes bytes. No Authorization header: the URL carries its own signature. A 403 from S3 means the URL expired or the headers or length differ from what was signed.
Confirms the object is in place, records its size and ETag, measures read files for pricing, and marks the asset ready. Call it within 6 hours of presigning, or the upload is discarded (upload_too_late). Answers {"asset"}; 400 if the object is missing or empty.
Multipart upload (any size)
For read files larger than multipart_threshold_bytes, or whenever you prefer resumable parts. The whole upload must complete within upload.window_hours (48 h) of starting.
Body {"filename", "asset_type", "size_bytes"} (read types only). 201 {"asset", "upload": {"part_bytes", "part_count", "window_ends_at"}}. Part n (1-based) covers bytes (n-1)·part_bytes up to min(n·part_bytes, size_bytes).
Body {"part_numbers": [1, 2, ...]}, up to 8 distinct parts per call. Answers {"urls": {"1": "...", "2": "..."}}. Mint URLs just before you PUT each part; a URL that has expired answers 403 from S3, so mint it again rather than retrying.
The part's bytes, with Content-Length equal to that part's size. Parts may be uploaded in parallel.
What S3 holds so far: {"parts": [{"part_number", "size", "etag"}], "missing": [n, ...], "state", "part_bytes", "part_count", "expected_bytes", "window_ends_at"}. Use it to resume after an interruption.
Assembles the parts. 202 {"asset": {"state": "completing"}}; poll the sample or asset list until the asset is ready (or back to uploading with missing_parts if parts were absent). 409 upload_missing if S3 no longer has the upload.
Abandon an in-progress multipart upload. Answers {"asset"}.
Remove an asset and its bytes. Answers {"deleted": true}. A ready read file that a pending or still-staging analysis needs is refused with 409; a ready file an existing analysis already used is kept as that analysis's provenance.
Pricing & credits
Analyses are paid in credits. The price of an analysis is base[type] × bands, where the bands are unit_bytes (10 GiB) of gzipped reads and anything up to first_unit_bytes (16 GiB) counts as one band. human_pathogens has a base of 2; every other type 1. A plain, uncompressed FASTQ is priced on an estimate of its compressed size until its upload has been sampled. The server quotes every price; nothing is charged until you submit with the quoted expected_credits, and a failed analysis is refunded automatically.
What a read selection would cost before uploading it, from filenames and sizes alone. Body {"files": [{"filename", "size_bytes"}]} (one BAM, or one or two FASTQs). Answers per-type estimates plus processable and the pricing table. For gzipped reads the estimate equals the eventual quote.
The authoritative quote for a real submission: send the full submission body with "dry_run": true. Answers 200 {"credits", "billing", "size_tier", "admissible"}, with the refusal's fields merged in when admissible is false. Nothing is charged.
Buying credits
Credits are bought by card through Stripe. The flow is browser-oriented (it needs Stripe's client-side confirmation), so use the app's Buy credits dialog; the endpoints are listed for completeness.
Body {"credits": n} (1 to credit_purchase.max_credits). Answers {"client_secret", "credits", "amount_cents"} priced from credit_purchase in config.
Body {"payment_intent_id"} after the card is confirmed. Answers {"credits_added", "already_credited"}; idempotent with the webhook.
For volume pricing or invoicing, contact sales@fathom-genomics.com.
Analyses
Analysis types
| analysis_type | What it runs | Inputs beyond reads | parameters |
|---|---|---|---|
human_pathogens | Read QC, sample composition, then bacterial, viral and fungal pathogen identification with genome coverage; influenza A subtype and SARS-CoV-2 lineage when detected. Base price 2. | — | skip_qc (bool, default false) |
amr | Assembles the AMR-binned reads and types them with NCBI AMRFinderPlus. | — | skip_qc |
influenza_resistance_typing | Oseltamivir and adamantane resistance markers in influenza A reads. | — | skip_qc |
ribosomal_rna | Species-level typing of rRNA reads. | — | subunit: "16S" (bacteria & archaea) or "18S_28S" (fungi), required; skip_qc |
custom | Classifies the reads against a FASTA you supply. | input_refs.reference | skip_qc |
vcf_kmer_support | Checks whether each variant in a VCF is supported by the sample's k-mers. | input_refs.vcf_asset_id, input_refs.reference | kmer_size (default 31), min_alt_frac (0–1, default 0.15), min_kmer_count (≥1, default 2), coverage (optional override) |
protein_kmer_support | Translates reads and verifies proposed amino-acid variants. | input_refs.protein_variants_asset_id, input_refs.protein_reference_asset_id | protein_kmer_size (default 9), min_frame_ref_kmer_hits (≥1, default 1), min_alt_frac, min_kmer_count, coverage |
Older type names (pathogen_classification, who_priority, bacteria, viral, fungal) are retired: existing results still load, new submissions answer 400 naming the replacement.
Submit an analysis. The sample must be ready.
Body
analysis_type | required, from the table above |
input_refs | Which reads: {"fastq_asset_ids": [r1_id]} or [r1_id, r2_id], or {"bam_asset_id": id}, or for an SRA sample {"sra_accession": "SRR..."} (the sample's own run). Plus, per type: "reference": {"kind": "sample_asset", "asset_id": id}, "vcf_asset_id", "protein_variants_asset_id", "protein_reference_asset_id". |
parameters | per type, see above; {} is fine where everything has a default |
label | optional, up to 120 characters |
expected_credits | the price you agree to pay: the credits from a dry_run quote, or from the last quote_required / price_changed answer |
dry_run | true to quote without submitting |
Responses
| 202 | {"analysis_id", "status": "pending", "credits_charged", "billing", "size_tier", "dispatch"}. The charge is made; the analysis runs asynchronously. |
| 409 | quote_required (no expected_credits) or price_changed (it differed): both carry credits and quote; resend with that value. |
| 402 | insufficient_credits with required and balance. |
| 422 | The input is larger than this type can process; the body explains the limit. |
| 400 | Wrong type, wrong asset types for the refs, R2 without R1 (409 missing_r1), bad parameters. |
| 503 | busy: nothing was charged, retry shortly. |
The analysis object
analysis_id, sample_id, sample_accession, analysis_type, label | Identity. |
status | pending → running → complete or failed. |
input_refs, parameters | Exactly what was run, with each input asset frozen (id, filename, size, ETag). |
credits_charged, billing, size_tier | What it cost and why. |
created_at, completed_at | ISO 8601 UTC. |
error, error_code, partial_result | On failure: the reason, and whether a partial result is still served. |
refunded_at, refund_reason | Set when a failed analysis was refunded. |
pipeline_version, seed_align_version / tool_version | Provenance: the pipeline and classifier build, or the k-mer tool version. |
{"analyses": [...]}, newest first; limit 1 to 100 (default 100).
{"analysis": {...}, "result": {...}}. result is present once the status is complete (or failed with partial_result). Poll this to track progress; a few seconds between polls is plenty.
Results & artifacts
The result object
A JSON document assembled by the pipeline. Its top-level sections depend on the analysis type; the common ones are:
total_reads_processed, classified_reads, unclassified_reads, reporting_categories, reporting_fractions, top_reporting_category | Sample composition: read counts and fractions by category (human, bacteria, virus, fungi, ambiguous, unknown). |
qc | Headline read-QC numbers from fastp (the full report is the qc_metrics.json artifact). |
bacteria_classification, viral_classification, fungal_classification | Per kingdom: organisms with reads, coverage, spread and detection status, plus totals. Reporting names and complex roll-ups (for example Mycobacterium tuberculosis complex) are applied. |
influenza, sarscov2_lineage, viral_gates | Influenza A subtype, SARS-CoV-2 lineage, and whether each was evaluated (so "looked for, not found" is distinct from "never run"). |
amr | AMRFinderPlus calls by gene and drug class. |
rrna_classification, rrna_taxonomy | Ribosomal RNA typing. |
model_confidence | Per-kingdom BLAST read-back sidecars where computed. For each assessed organism, n reads with the most k-mer hits for it were BLASTed against the kingdom database and target_pid_over_90 of them aligned back to it at over 90% identity; the application shows that count as Low (fewer than 25), Medium (25–75) or High (more than 75). A read count, not a probability. |
pipeline | Provenance: pipeline name and version, databases, thresholds. |
partial | Present on a partial result from a failed run. |
The complete normalised read-QC report (every fastp field, including the per-cycle curves) as JSON. 404 when QC was skipped.
Paged variant calls for vcf_kmer_support and protein_kmer_support. Query call filters by verdict (ALL, SUPPORTED, NOT_SUPPORTED, NO_INFO, SKIPPED; protein also NO_COVERAGE, AMBIGUOUS) and cursor is the page number from 0. Answers {"variants": [...], "next_cursor": n | null}.
A download link for one of the files the analysis published: {"url": "..."}, a pre-signed URL valid for 15 minutes that serves the file as an attachment named <accession>.<run>.<name>. 404 for a name this analysis cannot have written; 410 artifact_expired for read-level files (amr_reads_*.fastq.gz, *tied_reads.tsv.gz) after the sample's reads were deleted. SRA-backed analyses publish no read-level files.
Artifact names by type
| every type except the k-mer tools | qc_metrics.json, qc_report.html |
human_pathogens | per kingdom <k>_classification.json, <k>_taxonomy.json, <k>_taxa.tsv, <k>_taxonomy.tsv.gz, <k>_coverage.json, <k>_subset.json, <k>_no_coverage.tsv for k in bacteria, viral, fungal; plus viral_gates.json, influenza.json, sarscov2.json |
amr | amrfinder.tsv, amr_report.json, amr_coverage.json, amr_organisms.json, amr_contigs.fasta.gz, amr_reads_R1.fastq.gz, amr_reads_R2.fastq.gz |
influenza_resistance_typing | influenza_resistance.json, oseltamivir.report.tsv, oseltamivir.annotated.vcf.gz, adamantane.report.tsv, adamantane.tied_reads.tsv.gz, adamantane.annotated.vcf.gz |
ribosomal_rna | rrna_classification.json, rrna_taxonomy.json, rrna_taxa.tsv, rrna_taxonomy.tsv.gz, rrna_alignment.tsv, rrna_consensus.fasta, rrna_pid.tsv |
custom | the QC pair only; the classification is in the result |
vcf_kmer_support | report.tsv, annotated.vcf.gz |
protein_kmer_support | report.tsv, tied_reads.tsv.gz |
NCBI SRA input
Instead of uploading, a sample can point at a public SRA run. The reads are downloaded fresh for each analysis and never stored. Availability is per account while the feature rolls out: check allowed first.
{"enabled": bool, "allowed": bool}: whether SRA input is on for everyone, and whether you may use it.
Resolve a run before creating a sample. 200 {"ok": true, "run": {...}, "derived": {...}, "existing_sample_id"}, or 200 {"ok": false, "code", "message", "run"} when the policy refuses it (controlled access, unsupported platform, too large). 400 for a malformed or container accession (give a run, not a study or experiment); 429 on the per-user limit; 503 when SRA is off or NCBI is unreachable.
Then POST /api/samples with sra_accession, and submit analyses with "input_refs": {"sra_accession": "SRR..."}. Pricing uses the run's compressed size as frozen on the sample.
Box, S3 & single sign-on
These flows involve a browser redirect or a stored connection and are meant to be driven from the app. They are listed so the surface is complete.
| Endpoint | Purpose |
|---|---|
GET /api/box/status, GET /api/box/auth-url, GET /api/box/callback, POST /api/box/disconnect, GET /api/box/files | Connect a Box account (OAuth) and browse it. |
POST /api/samples/{sample_id}/assets/import-box | Import read files from Box into a sample as a background job; the assets appear with source: "box". |
GET /api/s3/status, POST /api/s3/connect, POST /api/s3/disconnect, GET /api/s3/objects | Store a scoped AWS key (encrypted at rest) and list a bucket. |
POST /api/samples/{sample_id}/assets/import-s3 | Import read objects from your bucket into a sample. |
GET /api/auth/{provider}/start, GET /api/auth/{provider}/link-url, GET /api/auth/{provider}/callback, POST /api/auth/sso/unlink | Google and Microsoft sign-in and account linking (provider is google or microsoft). |
POST /api/credits/webhook | Stripe's webhook; not for clients. |