Documentation

Everything you need to integrate the Duty27 calculation API — from your first request to full error handling.

A machine-readable OpenAPI 3.0 spec covering every endpoint on this page is also available, for SDK generators and API tooling.

Quickstart

1. Create an account

Sign up at duty27.com/signup and confirm your email. New accounts start on the Free tier — 500 calculations/month, no credit card required.

2. Get your API credentials

Your client ID and secret are generated automatically when your signup is confirmed — by the time you first open your account page, they're already there. The secret is shown in plaintext exactly once — copy it then. After that it's hidden for good; use Regenerate key if you ever need to see it again (this invalidates the old one).

3. Exchange your credentials for an access token

Duty27 uses the OAuth2 client_credentials grant. POST your client ID and secret to Cognito's token endpoint to get a short-lived access token.

curl
curl -X POST 'https://auth.duty27.com/oauth2/token' \
  -H 'Content-Type: application/x-www-form-urlencoded' \
  -u 'YOUR_CLIENT_ID:YOUR_CLIENT_SECRET' \
  -d 'grant_type=client_credentials&scope=duty27-api/access'
JavaScript
const res = await fetch('https://auth.duty27.com/oauth2/token', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/x-www-form-urlencoded',
    'Authorization': 'Basic ' + btoa('YOUR_CLIENT_ID:YOUR_CLIENT_SECRET')
  },
  body: 'grant_type=client_credentials&scope=duty27-api/access'
});
const { access_token } = await res.json();
Python
import requests

res = requests.post(
    'https://auth.duty27.com/oauth2/token',
    auth=('YOUR_CLIENT_ID', 'YOUR_CLIENT_SECRET'),
    data={'grant_type': 'client_credentials', 'scope': 'duty27-api/access'}
)
access_token = res.json()['access_token']

4. Make your first calculation

Send the access token as a Bearer token on POST /v1/calculate.

curl
curl -X POST 'https://api.duty27.com/v1/calculate' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "country_code": "DE",
    "date": "2026-08-19",
    "amount": "100.00",
    "amount_type": "NET",
    "b2b": false
  }'
JavaScript
const res = await fetch('https://api.duty27.com/v1/calculate', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${access_token}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    country_code: 'DE',
    date: '2026-08-19',
    amount: '100.00',
    amount_type: 'NET',
    b2b: false
  })
});
const result = await res.json();
Python
import requests

res = requests.post(
    'https://api.duty27.com/v1/calculate',
    headers={'Authorization': f'Bearer {access_token}'},
    json={
        'country_code': 'DE',
        'date': '2026-08-19',
        'amount': '100.00',
        'amount_type': 'NET',
        'b2b': False
    }
)
result = res.json()

Authentication

Duty27 uses OAuth2 client_credentials — no user is involved, your backend authenticates as itself using the client ID and secret from your account page.

Token endpoint

POST your credentials as HTTP Basic auth (or client_id/client_secret form fields) to https://auth.duty27.com/oauth2/token with grant_type=client_credentials. You'll get back a short-lived access_token.

Scope

Request scope=duty27-api/access. This is the only scope your credentials are authorized for.

Token lifetime

Access tokens are short-lived. There's no refresh-token flow for machine-to-machine credentials — when a token expires, request a new one the same way you got the first.

Note: a missing, malformed, expired, or wrong-scope token gets you a 401/403 on /v1/calculate with a plain { message } body — not the { error, message } shape used by every other documented error code below. Don't build your error parser assuming every response has a top-level error field; request a fresh token and retry on 401/403 instead.

Which routes use this token

This access token is used for POST /v1/calculate and the transaction-archive write routes: POST /v1/transactions, POST /v1/transactions/{orderId}/evidence, and POST /v1/transactions/{orderId}/refund. Everything else — reading back an archived transaction, usage, credentials, billing — lives on the web dashboard and uses your browser session instead.

Endpoints

POST /v1/calculate

Calculates VAT for a single sale.

FieldTypeRequiredNotes
country_codestringYesISO code for an EU member state, e.g. DE, FR, IT
datestring (YYYY-MM-DD)YesThe date the rate applies as of — used for effective-dated rate lookups, not just today's rate
amountstring/decimalYes>= 0.00
amount_type"NET" | "GROSS"YesNET adds VAT on top of amount; GROSS treats amount as already including VAT
currencystringNoISO 4217 code, e.g. "GBP". Optional — defaults to EUR when omitted. Used ONLY to convert amount to EUR for the €10,000 cross-border threshold check below (cross_border_rate_basis) — the VAT calculation itself is a currency-agnostic percentage and is unaffected either way. If your amounts are ever in a non-EUR currency and your account has a configured origin country, send this — otherwise the threshold check silently treats the raw number as EUR, which can misjudge the €10,000 line for a currency worth more than EUR.
b2bbooleanNoTriggers reverse-charge VAT-number validation when true. Optional — omitting it defaults to false (non-B2B). Available on every tier: Free (and any paid request with mode: "BASIC") runs an anonymous VIES check; paid tiers otherwise run a qualified check that requires a verified seller VAT ID — see seller_vat_country_override/seller_vat_number_override and SELLER_VAT_ID_REQUIRED in the error reference.
buyer_vat_numberstringIf b2bNo country prefix — 123456789, not DE123456789
seller_vat_country_overridestringNoPaid tiers only. The seller's own VAT country for this specific request, replacing the one on file from checkout. Must be supplied together with seller_vat_number_override, or not at all — one without the other is a 400 INVALID_REQUEST. Silently ignored (no error) on Free tier or when mode is BASIC.
seller_vat_number_overridestringNoPaid tiers only. The seller's own VAT number for this specific request, no country prefix. See seller_vat_country_override — same rules apply.
mode"FULL" | "BASIC"NoFULL (default, or if omitted) runs a qualified VIES check on paid tiers, which requires a verified seller VAT ID. BASIC skips that requirement and runs the same anonymous check Free tier always gets — useful for prefilling buyer name/address without a seller VAT ID on file. Ignored on Free tier, which always runs the anonymous check regardless of this field.
on_validation_unavailable"REJECT" | "ASSUME_INVALID"NoOnly consulted when b2b is true and the VAT-number check comes back unavailable. Omitting it (or REJECT) fails the request with 503 VAT_VALIDATION_UNAVAILABLE. ASSUME_INVALID proceeds instead — VAT is charged and no reverse charge applies, exactly as if the buyer's number had come back invalid.
category"EBOOK" | "NEWSPAPER" | "PERIODICAL"NoOmit for the standard rate. Falls back to the standard rate if no category-specific rate is loaded for that country/date.

Response fields:

FieldNotes
country_codeEchoed from the request
dateEchoed from the request
rate_typeOne of DEFAULT (standard rate), REDUCED_RATE, SUPER_REDUCED_RATE, EXEMPTED, OUT_OF_SCOPE, or NOT_APPLICABLE
rate_percentThe VAT rate applied, as a percentage
net_amountString, not a number — parse before doing arithmetic (kept as a string to avoid floating-point precision loss on currency values)
tax_amountString, not a number — parse before doing arithmetic (kept as a string to avoid floating-point precision loss on currency values)
gross_amountString, not a number — parse before doing arithmetic (kept as a string to avoid floating-point precision loss on currency values)
reverse_chargetrue if reverse-charge applied (validated B2B buyer)
vat_validation_statusNull for non-B2B requests (VIES is never called). For B2B requests, the underlying VAT-number check result — VALID, INVALID, or UNAVAILABLE — regardless of how reverse_charge was decided. UNAVAILABLE only appears here (rather than a 503) when the request opted into on_validation_unavailable=ASSUME_INVALID.
buyer_nameThe buyer's registered name from VIES. Empty string when b2b is false, VIES has no name on file, or the buyer's member state withholds it entirely — see buyer_name_masked to tell those apart.
buyer_name_maskedtrue when the buyer's member state withholds the name field from VIES, rather than it simply being unset.
buyer_addressSame as buyer_name, for the buyer's registered address.
buyer_address_maskedSame as buyer_name_masked, for buyer_address.
vat_consultation_numberVIES's proof-of-check identifier. Only present when a qualified check ran (paid tier, mode not BASIC) and VIES returned one — null for every anonymous check, including all of Free tier.
vat_validation_cachedtrue if this VAT-number verdict was served from cache rather than a live VIES call this request made. Always false when b2b is false. For a qualified (paid-tier, non-BASIC) check, this can only be true as an outage fallback: VIES was called live and came back unavailable, and a previously cached verdict for this VAT number was served instead of a 503.
sourceAlways "European Commission TEDB"
rate_effective_fromThe date this rate window began applying
categoryEchoes the request's category, or null
cross_border_rate_basisSet only for a non-B2B sale where your account has a configured origin country different from country_code. "NOT_TRACKED" if your account has no verified transaction history to base ORIGIN-rate eligibility on (destination-country rate is used, same as "DESTINATION"); otherwise "ORIGIN" if this sale is still under the €10,000 annual cross-border threshold (EU VAT Directive Article 59c), "DESTINATION" once it's crossed. Null otherwise, including every B2B request — reverse charge already covers that case. The threshold itself is always compared in EUR — see the currency field above.

Every successful response also carries an X-Monthly-Calls-Remaining header — how many of your plan's included calls are left this billing period (Free: your remaining calls before you're capped; Growth/Scale: your remaining calls before overage billing starts, floored at 0). FREE_TIER_LIMIT_EXCEEDED responses carry it too, reading 0. RATE_LIMIT_EXCEEDED responses instead carry a Retry-After header — seconds until you can safely retry.

curl
curl -X POST 'https://api.duty27.com/v1/calculate' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "country_code": "FR",
    "date": "2026-08-19",
    "amount": "250.00",
    "amount_type": "GROSS",
    "b2b": true,
    "buyer_vat_number": "123456789",
    "seller_vat_country_override": "DE",
    "seller_vat_number_override": "987654321"
  }'
JavaScript
const res = await fetch('https://api.duty27.com/v1/calculate', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    country_code: 'FR',
    date: '2026-08-19',
    amount: '250.00',
    amount_type: 'GROSS',
    b2b: true,
    buyer_vat_number: '123456789',
    seller_vat_country_override: 'DE',
    seller_vat_number_override: '987654321'
  })
});
if (res.status === 429) {
  const retryAfterSeconds = res.headers.get('Retry-After');
  // wait retryAfterSeconds, then retry
}
const result = await res.json();
Python
import requests

res = requests.post(
    'https://api.duty27.com/v1/calculate',
    headers={'Authorization': f'Bearer {access_token}'},
    json={
        'country_code': 'FR',
        'date': '2026-08-19',
        'amount': '250.00',
        'amount_type': 'GROSS',
        'b2b': True,
        'buyer_vat_number': '123456789',
        'seller_vat_country_override': 'DE',
        'seller_vat_number_override': '987654321'
    }
)
if res.status_code == 429:
    retry_after_seconds = res.headers.get('Retry-After')
    # wait retry_after_seconds, then retry
result = res.json()

POST /v1/transactions

Archives a completed sale as a permanent, tamper-evident record for OSS reporting and audits, and — for a qualifying cross-border B2C sale — accrues its value toward the €10,000 annual Article 59c threshold that /v1/calculate's cross_border_rate_basis reads. Independent of /v1/calculate: a rate lookup never guarantees a sale happened, so nothing here is written until you call this endpoint with a real, completed transaction.

Requires a paid tier: Scale, or Growth with the Transaction Archive Add-on. Free tier always gets 403 TIER_NOT_ELIGIBLE; Growth without the add-on gets 402 TRANSACTION_ARCHIVE_ADDON_REQUIRED.

Request fields (all under a JSON body with snake_case keys):

FieldTypeRequiredNotes
order_idstringYesYour own unique identifier for this sale — becomes part of the permanent record's storage key. Max 200 characters.
transaction_timestampstring (ISO 8601)YesISO 8601 instant, e.g. 2026-08-25T12:15:00Z. Determines which quarter this record is filed under, and which ECB reference rate is used for currency conversion (see currency below).
currencystringYesUppercase 3-letter ISO 4217 code, e.g. EUR, USD, GBP — otherwise 400 INVALID_CURRENCY_FORMAT. EUR needs no conversion. For a non-EUR, cross-border, B2C sale (see below), this must be a currency ECB publishes a reference rate for, on or before transaction_timestamp's date — otherwise 422 NO_EXCHANGE_RATE_AVAILABLE.
net_amountdecimalYesThe sale's net (pre-VAT) amount, in currency above.
vat_amountdecimalYesThe VAT amount actually charged. Zero on a B2B sale means a reverse-charge claim — see evidence.vies_consultation_number below.
vat_ratedecimalYesThe VAT rate applied, as a percentage — your own record of what was charged, not re-validated against /v1/calculate.
vat_typestringYesYour own classification of this line, e.g. STANDARD, REDUCED, EXEMPT — free text, stored as-is.
country_codestringYesThe buyer's country.
payment_mechanismstringYesOne of CD, CH, DC, CC, BT, GC, PP, OT — the SAF-OSS schema's own PaymentMechanismEnum_Type values, used verbatim — otherwise 400 INVALID_PAYMENT_MECHANISM.
customer.type"B2B" | "B2C"Yes"B2B" or "B2C".
customer.namestringNoRequired in spirit for B2B, not enforced server-side — a B2C sale legitimately has none.
customer.vat_numberstringNoNo country prefix, same format as /v1/calculate's buyer_vat_number. Null for B2C.
customer.billing_addressobjectNostreet, city, postal_code, country_code — all optional, but customer.billing_address.country_code compared against evidence.card_bin_country produces an EVIDENCE_MISMATCH warning if they disagree.
evidence.ip_addressstringNoMust be a real public IPv4/IPv6 address — a loopback/link-local/private/multicast address returns 400 RESERVED_IP_ADDRESS, and anything unparseable returns 400 INVALID_IP_FORMAT.
evidence.card_bin_countrystringNoThe country the buyer's card BIN resolves to, if you have it.
evidence.vies_consultation_numberstringNoA qualified /v1/calculate check's vat_consultation_number. Required when customer.type is B2B and vat_amount is 0 (a reverse-charge claim needs real proof-of-check, not just an unverified buyer VAT number) — otherwise 422 INVALID_VIES_REFERENCE.
evidence.bank_countrystringNoThe country the buyer's bank account is held in, if you have it.
evidence.mobile_country_codestringNoThe buyer's mobile country code, if you have it.
evidence.other_evidencestringNoFree text for any other location evidence not covered by the typed fields above. Max 500 characters.
evidence.evidence_relied_uponarray of stringNoYour own record of which evidence field(s) you actually relied on to determine the buyer's location — never computed by Duty27. Max 50 characters per entry.

Cross-border threshold accrual: for a B2C sale where your account has a verified origin country on file, different from country_code, and BOTH are EU member states, this sale's net amount accrues toward the running €10,000 Article 59c total /v1/calculate's cross_border_rate_basis reads (see that field's own note). A sale to or from a non-EU country is out of scope entirely and never accrues, even though origin and destination differ. B2B sales never accrue — reverse charge already covers that case.

Currency conversion: a non-EUR cross-border sale's net_amount is converted to EUR before accruing, using the last ECB reference rate published on or before transaction_timestamp's date, divided (never inverted) and rounded to the nearest cent with exact half-cent results rounding up — the same convention EU Regulation 1103/97 mandates for euro conversions. If no rate is on file for that currency/date, the request is rejected with 422 NO_EXCHANGE_RATE_AVAILABLE before anything is archived, rather than silently mistracking the total.

Response fields (201):

FieldNotes
order_idEchoed from the request
record_idA UUID identifying this archived record.
retained_untilISO 8601 instant — this record's statutory 10-year retention deadline. The record is permanent and tamper-evident (S3 Object Lock, compliance mode) until then; it cannot be deleted or edited by anyone, including Duty27, even on request.
warningsA list of { code, message } objects for non-blocking issues — the transaction was still archived successfully. EVIDENCE_MISMATCH: evidence.card_bin_country and customer.billing_address.country_code disagree. INSUFFICIENT_EVIDENCE: fewer than 2 of ip_address, card_bin_country, vies_consultation_number were supplied. Empty array when there's nothing to flag.
curl
curl -X POST 'https://api.duty27.com/v1/transactions' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "order_id": "inv-2026-001234",
    "transaction_timestamp": "2026-08-25T12:15:00Z",
    "currency": "EUR",
    "net_amount": 100.00,
    "vat_amount": 20.00,
    "vat_rate": 20.00,
    "vat_type": "STANDARD",
    "country_code": "FR",
    "customer": {
      "type": "B2C",
      "name": "Jane Buyer",
      "billing_address": {
        "street": "1 Rue Exemple",
        "city": "Paris",
        "postal_code": "75001",
        "country_code": "FR"
      }
    },
    "evidence": {
      "ip_address": "203.0.113.5",
      "card_bin_country": "FR"
    }
  }'
JavaScript
const res = await fetch('https://api.duty27.com/v1/transactions', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    order_id: 'inv-2026-001234',
    transaction_timestamp: '2026-08-25T12:15:00Z',
    currency: 'EUR',
    net_amount: 100.00,
    vat_amount: 20.00,
    vat_rate: 20.00,
    vat_type: 'STANDARD',
    country_code: 'FR',
    customer: {
      type: 'B2C',
      name: 'Jane Buyer',
      billing_address: {
        street: '1 Rue Exemple',
        city: 'Paris',
        postal_code: '75001',
        country_code: 'FR'
      }
    },
    evidence: {
      ip_address: '203.0.113.5',
      card_bin_country: 'FR'
    }
  })
});
const result = await res.json();
Python
import requests

res = requests.post(
    'https://api.duty27.com/v1/transactions',
    headers={'Authorization': f'Bearer {access_token}'},
    json={
        'order_id': 'inv-2026-001234',
        'transaction_timestamp': '2026-08-25T12:15:00Z',
        'currency': 'EUR',
        'net_amount': 100.00,
        'vat_amount': 20.00,
        'vat_rate': 20.00,
        'vat_type': 'STANDARD',
        'country_code': 'FR',
        'customer': {
            'type': 'B2C',
            'name': 'Jane Buyer',
            'billing_address': {
                'street': '1 Rue Exemple',
                'city': 'Paris',
                'postal_code': '75001',
                'country_code': 'FR'
            }
        },
        'evidence': {
            'ip_address': '203.0.113.5',
            'card_bin_country': 'FR'
        }
    }
)
result = res.json()

POST /v1/transactions/{orderId}/evidence

Attaches follow-up evidence to an order that got an EVIDENCE_MISMATCH or INSUFFICIENT_EVIDENCE warning when archived — for example a card BIN pulled from your payment processor's own records after the fact. The original record is never edited or replaced; this writes a separate, equally permanent record alongside it.

This is a documentation aid, not a compliance determination — Duty27 doesn't decide whether your evidence is sufficient, doesn't re-derive or change vat_amount/vat_rate on the original sale, and never marks a warning as "resolved." The warnings returned below tell you what the combined evidence looks like now; whether that's enough for your own recordkeeping is your call, same as vat_type and vat_rate are your own inputs elsewhere in this API.

Only accepted for an order that actually has a warning on file — an order archived cleanly returns 409 NO_WARNING_ON_RECORD. Requires the same tier as archiving itself (Scale, or Growth with the add-on).

Query parameters (both required, no default):

FieldTypeRequiredNotes
yearintegerYesThe year the original transaction was archived under. Required — there is no current-year default, since year and quarter select the exact storage partition your evidence is filed into; a wrong value returns 404 TRANSACTION_NOT_FOUND rather than finding the record.
quarterinteger (1-4)YesThe quarter (1-4) the original transaction was archived under. Same requirement and reasoning as year above.

Request fields (all under a JSON body with snake_case keys; at least one of ip_address, card_bin_country, vies_consultation_number, bank_country, mobile_country_code, other_evidence, or note must be non-blank):

FieldTypeRequiredNotes
sourcestringYesRequired on every submission — where this evidence came from, e.g. "payment processor records", "customer support correspondence". Lets you (or an auditor) judge how much weight to give it later; Duty27 doesn't score or rank sources.
ip_addressstringNoSame format rules as evidence.ip_address on the original archive call.
card_bin_countrystringNoSame shape as the original field.
vies_consultation_numberstringNoRejected with 400 VIES_REFERENCE_NOT_APPLICABLE if the order's customer.type is B2C — this field can only ever be the output of a qualified VIES check on a business's VAT number, so it's never a truthful supplement for a sale with no VAT number to check in the first place.
bank_countrystringNoSame shape as the original field.
mobile_country_codestringNoSame shape as the original field.
other_evidencestringNoSame shape as the original field. Free text for any other location evidence not covered by the typed fields above.
evidence_relied_uponarray of stringNoSame shape as the original field. Doesn't count toward this endpoint's own non-blank requirement — see above.
notestringNoFree text. Carries less evidentiary weight than the structured fields above on its own — pair it with a source that says how you gathered it.

Response fields (201):

FieldNotes
supplement_idA UUID identifying this supplement.
submitted_atISO 8601 instant this supplement was recorded.
warningsThe same { code, message } shape /v1/transactions returns, recomputed against the union of the original evidence and every supplement on file for this order, including this one. An empty array means the combined evidence no longer trips either warning check — not a statement that the order is compliant.

Every supplement you've submitted for an order is returned alongside the original record from your dashboard's own transaction lookup, and referenced (order_id + submitted_at, not the evidence content itself) in a dedicated file in your quarterly OSS report package — a paper trail to hand an auditor, without Duty27 asserting anything about what it proves.

curl
curl -X POST 'https://api.duty27.com/v1/transactions/inv-2026-001234/evidence?year=2026&quarter=3' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "source": "payment processor records",
    "card_bin_country": "FR"
  }'
JavaScript
const res = await fetch('https://api.duty27.com/v1/transactions/inv-2026-001234/evidence?year=2026&quarter=3', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    source: 'payment processor records',
    card_bin_country: 'FR'
  })
});
const result = await res.json();
Python
import requests

res = requests.post(
    'https://api.duty27.com/v1/transactions/inv-2026-001234/evidence',
    params={'year': 2026, 'quarter': 3},
    headers={'Authorization': f'Bearer {access_token}'},
    json={
        'source': 'payment processor records',
        'card_bin_country': 'FR'
    }
)
result = res.json()

POST /v1/transactions/{orderId}/refund

Records a full or partial refund against a previously archived transaction, for OSS reporting and audits. The original record is never edited; this writes a separate, equally permanent record alongside it.

Requires the same tier as archiving itself: Scale, or Growth with the Transaction Archive Add-on. Free tier gets 403 TIER_NOT_ELIGIBLE; Growth without the add-on gets 402 TRANSACTION_ARCHIVE_ADDON_REQUIRED.

Query parameters (both required, no default) — these identify the ORIGINAL transaction's storage partition, not the refund's own filing period:

FieldTypeRequiredNotes
yearintegerYesThe year the original transaction was archived under (from its own transaction_timestamp) — not the current year. Required, since year and quarter select the exact storage partition the original record is filed into.
quarterinteger (1-4)YesThe quarter (1-4) the original transaction was archived under. Same requirement and reasoning as year above.

Request fields (all under a JSON body with snake_case keys, amounts in the original transaction's currency):

FieldTypeRequiredNotes
refund_amount_netdecimalYesThe net (pre-VAT) amount being refunded. Must be greater than zero.
refund_amount_vatdecimalYesThe VAT amount being refunded. Zero is allowed — a reverse-charge sale can have vat_amount = 0 and still have a refundable net amount.
reasonstringYesYour own free-text reason for the refund — stored as-is.

Response fields (201):

FieldNotes
refund_idA UUID identifying this refund record.
filed_atISO 8601 instant this refund was recorded.
filing_yearThe year this refund itself is filed under — today's year at the time of the request, not the original transaction's year.
filing_quarterThe quarter (1-4) this refund itself is filed under — today's quarter at the time of the request, not the original transaction's quarter.
curl
curl -X POST 'https://api.duty27.com/v1/transactions/inv-2026-001234/refund?year=2026&quarter=3' \
  -H 'Authorization: Bearer YOUR_ACCESS_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "refund_amount_net": 50.00,
    "refund_amount_vat": 10.00,
    "reason": "Partial return"
  }'
JavaScript
const res = await fetch('https://api.duty27.com/v1/transactions/inv-2026-001234/refund?year=2026&quarter=3', {
  method: 'POST',
  headers: {
    'Authorization': `Bearer ${accessToken}`,
    'Content-Type': 'application/json'
  },
  body: JSON.stringify({
    refund_amount_net: 50.00,
    refund_amount_vat: 10.00,
    reason: 'Partial return'
  })
});
const result = await res.json();
Python
import requests

res = requests.post(
    'https://api.duty27.com/v1/transactions/inv-2026-001234/refund',
    params={'year': 2026, 'quarter': 3},
    headers={'Authorization': f'Bearer {access_token}'},
    json={
        'refund_amount_net': 50.00,
        'refund_amount_vat': 10.00,
        'reason': 'Partial return'
    }
)
result = res.json()

Errors

Every error response is a JSON body with a top-level error code and human-readable message. One error additionally nests endpoint-specific context under a details object: NO_RATE_DATA (422) nests country_code/date there.

StatusCodeEndpointMeaning
400INVALID_REQUEST/v1/calculatebuyer_vat_number is required when b2b is true. Also returned when only one of seller_vat_country_override/seller_vat_number_override is supplied without the other.
400UNRECOGNIZED_COUNTRY/v1/calculatecountry_code doesn't match an EU member state.
400DATE_OUT_OF_RANGE/v1/calculatedate is earlier than 2016-01-01 — rate data isn't available before this date.
402SELLER_VAT_ID_REQUIRED/v1/calculateb2b: true was sent on a paid tier without mode: "BASIC", but no verified seller VAT ID is on file and none was supplied via seller_vat_country_override/seller_vat_number_override.
403ACCOUNT_ARCHIVED/v1/calculateThis account has been archived and no longer has API access. Contact support if you believe this is a mistake.
422NO_RATE_DATA/v1/calculateNo rate data is available for that country/date combination.
429FREE_TIER_LIMIT_EXCEEDED/v1/calculateYour Free tier's monthly calculation limit is exceeded. Upgrade to unlock a higher limit. The X-Monthly-Calls-Remaining header reads 0.
429RATE_LIMIT_EXCEEDED/v1/calculatePer-minute rate limit exceeded for your plan. Slow down and retry. A Retry-After header tells you exactly how many seconds to wait.
500AUTH_CONTEXT_MISSING/v1/calculateNo resolvable client identity — misconfigured or absent authorizer. Shouldn't happen in normal use; contact support if you see this.
502SELLER_VAT_ID_INVALID/v1/calculateThe seller's own VAT ID — on file or overridden — was rejected by VIES, so the buyer's number was never checked. Not the buyer's fault; verify the seller VAT ID on the account.
503VAT_VALIDATION_UNAVAILABLE/v1/calculateThe VIES VAT-validation service is temporarily unreachable, and no previously cached verdict for this VAT number was available to serve instead. Retry the request.
400INVALID_IP_FORMAT/v1/transactionsevidence.ip_address is not a valid IPv4/IPv6 address.
400RESERVED_IP_ADDRESS/v1/transactionsevidence.ip_address must be a real public buyer address, not a reserved/internal one.
400INVALID_PAYMENT_MECHANISM/v1/transactionspayment_mechanism is missing or isn't one of the SAF-OSS PaymentMechanismEnum_Type values: CD, CH, DC, CC, BT, GC, PP, OT.
400INVALID_CURRENCY_FORMAT/v1/transactionscurrency is missing or isn't an uppercase 3-letter ISO 4217 code.
402TRANSACTION_ARCHIVE_ADDON_REQUIRED/v1/transactionsGrowth tier without the Transaction Archive Add-on. Manage add-ons from your billing portal, or upgrade to Scale.
403TIER_NOT_ELIGIBLE/v1/transactionsTransaction archiving is not available on the free tier — upgrade to Growth (with the add-on) or Scale.
403ACCOUNT_ARCHIVED/v1/transactionsThis account has been archived and no longer has API access. Contact support if you believe this is a mistake.
409ORDER_ID_ALREADY_EXISTS/v1/transactionsA transaction with this order_id has already been archived for your account in this quarter.
422INVALID_VIES_REFERENCE/v1/transactionscustomer.type is B2B and vat_amount is 0 (a reverse-charge claim), but evidence.vies_consultation_number is missing or isn't a plausible value from a qualified /v1/calculate check.
422NO_EXCHANGE_RATE_AVAILABLE/v1/transactionsThis is a non-EUR cross-border B2C sale, but no ECB reference rate is on file for that currency on or before transaction_timestamp's date. Nothing was archived.
500AUTH_CONTEXT_MISSING/v1/transactionsNo resolvable client identity — misconfigured or absent authorizer. Shouldn't happen in normal use; contact support if you see this.
503STORAGE_UNAVAILABLE/v1/transactionsThe archive storage backend is temporarily unreachable. Retry the request — nothing was archived.
400EMPTY_SUPPLEMENT/v1/transactions/{orderId}/evidenceNone of source's companion fields were supplied — source alone with no ip_address, card_bin_country, vies_consultation_number, bank_country, mobile_country_code, other_evidence, or note isn't a supplement.
400VIES_REFERENCE_NOT_APPLICABLE/v1/transactions/{orderId}/evidencevies_consultation_number was supplied for a B2C order — that field can only ever come from a qualified VIES check on a business's VAT number.
400INVALID_IP_FORMAT/v1/transactions/{orderId}/evidenceevidence.ip_address is not a valid IPv4/IPv6 address.
400RESERVED_IP_ADDRESS/v1/transactions/{orderId}/evidenceevidence.ip_address must be a real public buyer address, not a reserved/internal one.
400INVALID_PERIOD/v1/transactions/{orderId}/evidenceyear and/or quarter query parameter is missing, or quarter isn't 1-4.
402TRANSACTION_ARCHIVE_ADDON_REQUIRED/v1/transactions/{orderId}/evidenceGrowth tier without the Transaction Archive Add-on. Manage add-ons from your billing portal, or upgrade to Scale.
403TIER_NOT_ELIGIBLE/v1/transactions/{orderId}/evidenceTransaction archiving is not available on the free tier — upgrade to Growth (with the add-on) or Scale.
403ACCOUNT_ARCHIVED/v1/transactions/{orderId}/evidenceThis account has been archived and no longer has API access. Contact support if you believe this is a mistake.
404TRANSACTION_NOT_FOUND/v1/transactions/{orderId}/evidenceNo archived transaction matches that order_id/year/quarter.
409NO_WARNING_ON_RECORD/v1/transactions/{orderId}/evidenceThe order exists and archived cleanly — there's no warning to attach follow-up evidence to.
500AUTH_CONTEXT_MISSING/v1/transactions/{orderId}/evidenceNo resolvable client identity — misconfigured or absent authorizer. Shouldn't happen in normal use; contact support if you see this.
503STORAGE_UNAVAILABLE/v1/transactions/{orderId}/evidenceThe archive storage backend is temporarily unreachable. Retry the request — nothing was archived.
400INVALID_PERIOD/v1/transactions/{orderId}/refundyear and/or quarter query parameter is missing or invalid (not 1-4), or the original transaction's year/quarter is later than the current filing period.
500AUTH_CONTEXT_MISSING/v1/transactions/{orderId}/refundNo resolvable client identity — misconfigured or absent authorizer. Shouldn't happen in normal use; contact support if you see this.
403TIER_NOT_ELIGIBLE/v1/transactions/{orderId}/refundTransaction archiving is not available on the free tier — upgrade to Growth (with the add-on) or Scale.
403ACCOUNT_ARCHIVED/v1/transactions/{orderId}/refundThis account has been archived and no longer has API access. Contact support if you believe this is a mistake.
402TRANSACTION_ARCHIVE_ADDON_REQUIRED/v1/transactions/{orderId}/refundGrowth tier without the Transaction Archive Add-on. Manage add-ons from your billing portal, or upgrade to Scale.
404TRANSACTION_NOT_FOUND/v1/transactions/{orderId}/refundNo archived transaction matches that order_id/year/quarter.
400REFUND_WINDOW_EXPIRED/v1/transactions/{orderId}/refundThis transaction's OSS correction window (3 years from its return due date) has expired.
400REFUND_EXCEEDS_REMAINING_BALANCE/v1/transactions/{orderId}/refundThis refund, combined with any prior refunds against the same order, would exceed the original transaction's net_amount or vat_amount.
503STORAGE_UNAVAILABLE/v1/transactions/{orderId}/refundThe archive storage backend is temporarily unreachable. Retry the request — nothing was archived.

A malformed /v1/calculate request (e.g. a missing required field) also returns INVALID_REQUEST (400) in the same { error, message } shape as every other row above — there's no separate response format to handle.