Open API

Gooval REST API for self-hosted merchants. Base path: /open/v1

Overview

ItemDescription
Base URLhttps://{api-host}/open/v1
Content-Typeapplication/json
EncodingUTF-8
Field namingcamelCase
TimestampsISO 8601 UTC
Request tracingEvery response includes header X-Request-Id. You may send the same header on requests; if omitted, the server generates an id. See Errors.

Environments

EnvironmentBase URL
Productionhttps://sp-prod.gooval.io/open/v1
Developmenthttps://sp-test.gooval.io/open/v1

Set Postman variable {{baseUrl}} to the host root (without /open/v1), e.g. https://sp-prod.gooval.io.

Authentication

Every request must include HMAC signature headers. Never call the Open API from a browser or frontend — keep the API secret on your backend only.

Auth header names remain X-Gwofy-* (legacy naming, matches the current Gooval backend — send headers exactly as listed below).

Credentials

Before integrating, obtain storeNumber (store ID) and apiSecret (API secret). Contact Gooval to enable access:

After onboarding, Gooval provides storeNumber and apiSecret (shown once — store immediately on your server; never commit to git or expose in the client).

CredentialUsagePostman / Apifox variable
storeNumberHeader X-Gwofy-Store-NumberOPEN_API_STORE_NUMBER
apiSecretHMAC signing (not sent in headers)OPEN_API_SECRET

Headers

HeaderRequiredDescription
X-Gwofy-Store-NumberYesTenant store number
X-Gwofy-TimestampYesUnix timestamp (seconds), ±5 minutes
X-Gwofy-SignatureYesHMAC-SHA256 hex signature
Content-TypeYesapplication/json
Idempotency-KeyWrite opsIdempotency key; same key within 24h returns the same response
X-Request-IdNoOptional correlation id; echoed on the response header and in error JSON as requestId

Signature

message = storeNumber + "\n" + timestamp + "\n" + rawBody
signature = hex(hmac_sha256(apiSecret, message))
  • rawBody: raw HTTP request body string; empty string "" for GET
  • Signature is lowercase hexadecimal

Environment variables

VariableDescription
OPEN_API_STORE_NUMBERStore number
OPEN_API_SECRETAPI secret (server-side only)

Rate limits

Default per-store limits (60-second sliding window):

BucketRoutesDefault limit
quotesPOST /policies/quote6000/min
policies_writeapply / endorse / cancel30/min
attachmentsPOST /policies/attachments/upload-url120/min
policies_readGET policies, events, document120/min
reference_dataGET reference-data300/min

When exceeded, returns 429 with Retry-After header. Cache quote results server-side (30–60s) for checkout traffic.

Money

Amount fields use integer minor units (cents). For example, 10.00 USD is 1000.

  • Request bodies accept JSON numbers or digit strings (e.g. 1000 or "1000").
  • Decimal strings such as "10.00" are rejected.
  • Responses use the same integer minor units (premium, insuredAmount, unitPrice, premiumAmount, etc.).
  • coverage_out_of_range limit fields (minCoverage, maxCoverage, hardMaxAmount) use the same minor-unit shape — see Errors.
  • Use minor units on both quote and apply (e.g. USD 36.99 → 3699). Decimal major-unit strings can cause apply to fail even when quote appeared to work.

Error format

{
  "error": "error_code",
  "message": "Human-readable message",
  "field": "risk.insuredAmount",
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}

Responses use a stable machine error code and a human-readable message. Optional field names the JSON path when validation fails.

Request ID

Every HTTP response sets header X-Request-Id. Error JSON (HTTP 4xx/5xx, and any body that includes error — including 200 duplicate_external_reference on apply) also includes requestId with the same value.

  • You may send X-Request-Id on the request; otherwise the server generates one.
  • Idempotent replays return the current request's id, not the id from the cached response.
  • Include requestId in logs and support tickets so Gooval can locate the call in server logs.

Coverage limits

When insuredAmount is below the minimum or above the quotable cap, the API returns coverage_out_of_range with limit objects in integer minor units:

{
  "error": "coverage_out_of_range",
  "message": "insuredAmount is outside allowed coverage",
  "field": "risk.insuredAmount",
  "requestId": "550e8400-e29b-41d4-a716-446655440000",
  "minCoverage": { "amount": 1000, "currencyCode": "USD" },
  "maxCoverage": { "amount": 500000, "currencyCode": "USD" },
  "hardMaxAmount": { "amount": 1000000, "currencyCode": "USD" }
}
HTTPerrorDescription
401missing_auth_headersMissing auth headers
401invalid_signatureHMAC mismatch
401timestamp_expiredTimestamp expired (outside ±5 minutes)
429rate_limit_exceededToo many requests
404policy_not_foundUnknown policyNo
400field_requiredMissing schema field
400line_items_total_mismatchSum of lineItems amounts does not equal insuredAmount
400invalid_urlimageUrl is not a valid https URL
400invalid_emailbuyer.email is missing @ / domain, or otherwise invalid
400invalid_datetimeorderPlacedAt or estimatedArrivalDate is not a valid timestamp/date
400order_too_oldorderPlacedAt is more than 24 hours before apply time
400order_in_futureorderPlacedAt is after apply time
400invalid_attachment_keyattachmentKey is missing or belongs to another store
400too_many_attachmentsMore than 10 attachments
400invalid_content_typeUpload file type is not pdf/jpeg/png/webp
400invalid_content_lengthUpload size is missing or exceeds 5 MB
400currency_mismatchAmount currency does not match shop settlement currency
400shop_currency_not_configuredOpen tenant missing shopCurrencyCode
400country_not_supportedDestination country is not in the assigned protection catalog
400coverage_out_of_rangeinsuredAmount is below min or above max coverage. Body also includes minCoverage, maxCoverage (quoted cap), and hardMaxAmount — each { amount, currencyCode } in minor units
400premium_exceeds_maxinsuredAmount × country rate exceeds the highest price tier
400invalid_enumUnknown reference-data code
400invalid_merchant_shopmerchantShop is not an object
400invalid_metadatametadata is not a flat string map, a key is invalid, or a value exceeds 256 characters
400too_many_metadataMore than 20 metadata keys
400invalid_document_sourcesource is policy / insurer / reinsurer, or another unsupported value — Open API only issues Gooval confirmation PDFs
400invalid_document_langlang is not zh or en
409cancel_blocked_by_shipmentA tracking number is already present (including multi-shipment policies); whole-policy cancel is not allowed
409single_tracking_policyPolicy already has a single risk.trackingNumber; syncShipments is not allowed
400use_sync_shipmentsPolicy uses multi-shipment syncShipments; changeShipment is not allowed
409cancel_blocked_by_claimPolicy has a completed claim or an incomplete claim application; cancel is not allowed
200duplicate_external_referenceexternalReference already has an active policy (includes requestId like other errors)
502policy_apply_failedInsurer rejected apply (mapped)

Core flow

1. GET /reference-data/{collection} → load enums (countries, carriers, product-categories, endorse-scenes, …) 2. POST /policies/quote → checkout premium (optional; cache 30–60s server-side) 3. POST /policies/apply → bind policy on the Gooval platform (does not call external reinsurers synchronously) 4. Receive policyNo + externalReference (not spNumber) 5. Gooval emails buyer with spNumber for /claim/* lookup (new: {storeNumber}-{14 random digits}; legacy {storeNumber}-{externalReference} still valid) 6. Subscribe to outbound webhooks (Portal) or poll GET /events Multi-shipment: omit tracking at apply → POST .../endorse syncShipments with full shipment list (1–10)

Customer claims (/claim/*): one spNumber per policy; lookup returns all tracking numbers on trackingShipments. Reinsurer claim submission uses the tracking number on the claim to select that shipment's reinsurer policy.

Premium uses the same formula as the Shopify storefront: insured amount × destination-country rate (catalog rate + merchant markup), matched to the cheapest protection price tier at or above that fee. Quote uses the same pricing as apply.

On quote and apply, risk.insuredAmount.currencyCode (and optional risk.lineItems[].unitPrice) must match the tenant's shopCurrencyCode (single settlement currency per store). Amount values are integer minor units — see Money.

Reference data

GET /open/v1/reference-data Postman: List collections

List all reference-data collection names.

200 response

{
  "schemaVersion": "2026.07.1",
  "collections": [
    "countries", "currencies", "carriers", "product-categories",
    "destination-continent-categories", "goods-value-categories",
    "certificate-types", "endorse-scenes", "cancel-reasons",
    "claim-types", "policy-statuses", "claim-customer-statuses"
  ]
}
GET /open/v1/reference-data/{collection} Postman: Get reference data

Get enum items for a collection. Supports If-None-Match; returns 304 when unchanged.

Path parameters

ParameterDescription
collectione.g. product-categories, cancel-reasons, endorse-scenes, claim-types

200 example — cancel-reasons

{
  "schemaVersion": "2026.07.1",
  "collection": "cancel-reasons",
  "items": [
    { "code": "merchantRefund", "name": "Merchant refund before shipment" },
    { "code": "duplicatePolicy", "name": "Duplicate policy" },
    { "code": "customerWithdraw", "name": "Customer withdrawal" },
    { "code": "other", "name": "Other" }
  ]
}

Policies

POST /open/v1/policies/quote Postman: Quote

Calculate premium from insured amount; does not create a policy. Same pricing as apply: destination-country catalog rate + markup, matched to a price tier.

Quote does not require lineItems, orderPlacedAt, estimatedArrivalDate, buyer, or trackingNumber. It does not enforce the 24-hour order window.

merchantShop and metadata may be included but are ignored until apply. All amount fields use integer minor units — see Money.

Request body

FieldTypeRequiredDescription
externalReferencestringYesMerchant order ID
risk.insuredAmountobjectYes{ amount, currencyCode } — amount in minor units (cents)
risk.destinationobjectYesDestination address
risk.departureobjectYesOrigin address
risk.trackingNumberstringNoTracking number (optional at quote)
risk.carrierCodestringNo17track carrier code
risk.carrierNamestringNoCarrier display name

Example request

{
  "externalReference": "ORD-1001",
  "risk": {
    "trackingNumber": "1Z999AA10123456784",
    "carrierCode": "17",
    "carrierName": "UPS",
    "insuredAmount": { "amount": 18000, "currencyCode": "USD" },
    "destination": {
      "countryCode": "US", "city": "Los Angeles",
      "provinceCode": "CA", "addressLine": "123 Main St"
    },
    "departure": {
      "countryCode": "CN", "city": "Shenzhen",
      "addressLine": "Factory Zone A"
    }
  }
}

200 response

{
  "externalReference": "ORD-1001",
  "insuredAmount": { "amount": 18000, "currencyCode": "USD" },
  "premiumAmount": { "amount": 299, "currencyCode": "USD" },
  "quoteExpiresInSeconds": 60
}
POST /open/v1/policies/apply Postman: Apply

Bind a policy on the Gooval platform after shipment. Returns policyNo immediately. Reinsurance (if the catalog product is not self-retained) is submitted asynchronously and is never exposed to merchants.

Requires Idempotency-Key header. Monetary amount fields use integer minor units — see Money.

Key fields

FieldRequiredDescription
externalReferenceYesYour order ID
orderPlacedAtYesISO-8601 order time, e.g. 2026-08-20T09:15:00Z. Must be within the past 24 hours (now − 24h through now). Older orders return 400 order_too_old; future timestamps return 400 order_in_future.
risk.insuredAmountYesInsured amount in minor units (cents)
risk.destination / risk.departureYesAddress objects
risk.trackingNumberNoOptional until shipment. Single shipment: add or change with changeShipment (cannot cancel once set). Multiple shipments: omit at apply and use syncShipments endorse — see Multiple tracking numbers.
risk.estimatedArrivalDateNoYYYY-MM-DD. Omit at apply and supply later with changeShipment or per shipment in syncShipments. If omitted when reinsurance is submitted, Gooval defaults to sail date + 14 days.
risk.lineItemsYesMerchandise breakdown (see below)
buyer.name / buyer.emailYesBuyer contact; confirmation email contains spNumber
attachmentsNoInvoice / packing list / product photos (upload first)
merchantShopNoPlatform-internal shop identity for settlement split (id ≤ 64, name ≤ 200). Gooval still bills the Open tenant as a whole; not sent to the reinsurer.
metadataNoFlat string map (max 20 keys; keys [A-Za-z0-9._-], ≤ 40 chars; values ≤ 256 chars) for extra settlement tags.

Omitted merchantShop / metadata fields are not returned later. Endorsement and cancel do not change them.

Example request

{
  "externalReference": "ORD-1002",
  "orderPlacedAt": "2026-08-20T09:15:00Z",
  "merchantShop": { "id": "shop_8821", "name": "Acme US" },
  "metadata": { "channel": "tiktok", "settlementCode": "NA-01" },
  "risk": {
    "trackingNumber": "1Z999AA10123456784",
    "carrierCode": "17",
    "carrierName": "UPS",
    "insuredAmount": { "amount": 10000, "currencyCode": "USD" },
    "estimatedArrivalDate": "2026-08-25",
    "destination": {
      "countryCode": "US", "city": "Los Angeles",
      "provinceCode": "CA", "addressLine": "123 Main St, Los Angeles, CA"
    },
    "departure": {
      "countryCode": "CN", "city": "Shenzhen",
      "addressLine": "Factory Zone A"
    },
    "lineItems": [{
      "itemId": "LINE-001", "sku": "TEE-BLU-M", "name": "Blue Tee",
      "category": "apparel",
      "quantity": 2,
      "unitPrice": { "amount": 5000, "currencyCode": "USD" },
      "imageUrl": "https://cdn.merchant.com/tee.jpg"
    }]
  },
  "buyer": { "name": "Jane Doe", "email": "buyer@example.com" }
}

lineItems rules (required)

  • Each line requires name, category (from reference-data/product-categories), quantity (≥1), unitPrice, and at least one of sku or itemId
  • Sum of unitPrice × quantity must equal risk.insuredAmount (same currency, minor units)
  • Optional imageUrl must be https. Lines are returned in claim lookup as orderLineItems

Omitting lineItems returns 400 field_required. Missing or unknown category returns field_required / invalid_enum. Legacy policies bound before this change still fall back to a single virtual line at claim lookup.

If trackingNumber is omitted at apply, reinsurance waits until a later changeShipment or syncShipments supplies it. Apply does not take a shipment list.

If externalReference already has an active policy, apply returns 200 with error: duplicate_external_reference — use GET /policies?externalReference= or POST .../cancel before re-applying.

200 success (merchant-visible)

{
  "policyNo": "P2026070612345678",
  "externalReference": "ORD-1002",
  "status": "active",
  "premium": { "amount": 299, "currencyCode": "USD" },
  "insuredAmount": { "amount": 10000, "currencyCode": "USD" },
  "merchantShop": { "id": "shop_8821", "name": "Acme US" },
  "metadata": { "channel": "tiktok", "settlementCode": "NA-01" }
}

spNumber is never returned to merchants.

200 duplicate apply

Same shape as other errors: includes requestId (and response header X-Request-Id). Use GET /policies?externalReference= to fetch the existing policy, or cancel before re-applying.

{
  "error": "duplicate_external_reference",
  "message": "externalReference already has an active policy",
  "field": "externalReference",
  "requestId": "550e8400-e29b-41d4-a716-446655440000"
}
POST /open/v1/policies/attachments/upload-url Postman: Attachment upload URL

Get a presigned URL to upload policy attachments before apply.

  1. POST /open/v1/policies/attachments/upload-url with { "contentType", "contentLength", "type"?, "fileName"? }
  2. PUT the file to the returned uploadUrl (include the same Content-Type)
  3. Pass attachmentKey on apply

Allowed types: invoice, packingList, productPhoto, other. Files: PDF / JPEG / PNG / WebP, 1 byte–5 MB, max 10 per apply. This route is not 24h-idempotent (presigned URLs expire in 900 seconds).

"attachments": [
  {
    "attachmentKey": "open-policy-attachments/{storeNumber}/{uuid}.pdf",
    "type": "invoice",
    "fileName": "invoice.pdf"
  }
]

GET /policies/{policyNo} echoes orderPlacedAt and attachments (keys only; no download URL). Attachments are not sent to the reinsurer.

GET /open/v1/policies Postman: List policies

Query parameters

ParameterDefaultDescription
cursor—Previous page nextCursor
limit501–200
policyNo—Filter by policy number
externalReference—Filter by order ID
createdFrom / createdTo—ISO 8601 time range

200 response

{
  "items": [{
    "policyNo": "P2026070612345678",
    "externalReference": "ORD-1002",
    "status": "active",
    "orderName": "#1002",
    "premium": { "amount": 299, "currencyCode": "USD" },
    "createdAt": "2026-07-06T06:00:00+00:00"
  }],
  "nextCursor": "eyJ..."
}
GET /open/v1/policies/{policyNo}

Get a single policy including orderPlacedAt, risk.shipments when using multi-shipment endorse (tracking number, carrier, line items, insured amount per shipment), attachment keys (no download URLs), and merchantShop / metadata when set at apply. Reinsurer policy numbers are not included.

GET /open/v1/policies/{policyNo}/document

Get a pre-signed URL for the Gooval application confirmation PDF (documentType: confirmation). This is not an insurance policy.

The PDF is rendered from Gooval's HTML confirmation template (configured by Gooval Admin; en and zh layouts). Until a custom template is saved, the built-in default layout is used. Template updates change the PDF cache key — the next download regenerates the file.

Use ?lang=zh or ?lang=en (locale / language also accepted; default en). Reinsurer policy PDFs are not available on the Open API (source=policy returns 400 invalid_document_source).

{ "downloadUrl": "https://...", "expiresIn": 900, "documentType": "confirmation" }
POST /open/v1/policies/{policyNo}/endorse Postman: Endorse

Endorse a policy. Gooval applies the change immediately. If the policy is already submitted to a reinsurer, Ping An intelligentEndorse (edrSceneCode: ["900013"]) runs asynchronously and does not fail this request.

Request body

FieldRequiredDescription
endorseSceneYesSupported scenes from reference-data/endorse-scenes (see table below)
changesYesFields to change
reason / remarkNoNotes

endorseScene values

CodeMutable fields
changeDestinationdestination, claimPaymentAddress, destinationContinentCategory
changeShipmenttrackingNumber, carrierCode, carrierName, sailDepartureDate, estimatedArrivalDate (single-tracking policies only; after syncShipments use use_sync_shipments)
syncShipmentsFull changes.shipments snapshot (1–10) for multi-shipment policies — see Multiple tracking numbers

Example request

{
  "endorseScene": "changeShipment",
  "changes": {
    "trackingNumber": "1Z999AA10987654321",
    "carrierCode": "17",
    "carrierName": "UPS",
    "estimatedArrivalDate": "2026-08-28"
  },
  "reason": "Carrier re-issued tracking label",
  "remark": "Original label voided"
}
POST /open/v1/policies/{policyNo}/endorse endorseScene: syncShipments

Register or update multiple tracking numbers on one platform policy. Apply does not accept a shipment list — omit risk.trackingNumber at apply when the order has not shipped, then call endorse with endorseScene: syncShipments as shipments go out.

Rules

  • Each call sends the complete shipment list (1–10 shipments), not only new tracking numbers. A tracking number omitted from a later call is treated as removed.
  • Each shipment requires trackingNumber, carrierCode or carrierName, and lineItems (same line rules as apply; shipment insured amount in minor units).
  • Optional per shipment: estimatedArrivalDate, sailDepartureDate.
  • One platform policyNo. Reinsurance is submitted once per tracking number and is not exposed on this API. Unchanged tracking numbers are not resubmitted; new numbers are applied at the reinsurer; removed numbers that were already submitted are cancelled at the reinsurer while the platform policy stays active.
  • Policies that already have a single risk.trackingNumber cannot use syncShipments (409 single_tracking_policy). After the first syncShipments, changeShipment returns 400 use_sync_shipments.

Example request

{
  "endorseScene": "syncShipments",
  "changes": {
    "shipments": [
      {
        "trackingNumber": "1Z999AA10123456784",
        "carrierCode": "17",
        "carrierName": "UPS",
        "insuredAmount": { "amount": 9000, "currencyCode": "USD" },
        "estimatedArrivalDate": "2026-08-25",
        "lineItems": [{
          "itemId": "LINE-001",
          "sku": "TEE-BLU-M",
          "name": "Blue Tee",
          "category": "apparel",
          "quantity": 1,
          "unitPrice": { "amount": 9000, "currencyCode": "USD" }
        }]
      },
      {
        "trackingNumber": "1Z999AA10987654321",
        "carrierCode": "17",
        "insuredAmount": { "amount": 9000, "currencyCode": "USD" },
        "lineItems": [{
          "itemId": "LINE-002",
          "name": "Red Tee",
          "category": "apparel",
          "quantity": 1,
          "unitPrice": { "amount": 9000, "currencyCode": "USD" }
        }]
      }
    ]
  },
  "reason": "Order split into two packages"
}

GET /policies/{policyNo} echoes risk.shipments (tracking number, carrier, line items, insured amount). Reinsurer policy numbers are not included.

Whole-policy cancel (POST .../cancel) is only allowed before any tracking number exists. Removing one shipment in syncShipments is not a policy cancel. See Cancel.

POST /open/v1/policies/{policyNo}/cancel Postman: Cancel

Cancel a policy. Gooval cancels immediately only when the policy has no tracking number. If the policy is already submitted to a reinsurer, Ping An intelligentEndorse (edrSceneCode: ["00006"]) runs asynchronously and does not fail this request.

Request body

FieldRequiredDescription
cancelReasonCodeYesFrom reference-data/cancel-reasons
cancelReasonNoReason text
remarkNoRemark
{
  "cancelReasonCode": "merchantRefund",
  "cancelReason": "Order refunded before shipment",
  "remark": "Customer cancelled order ORD-1001"
}

200 response status is cancelled.

Returns 409 cancel_blocked_by_shipment when any tracking number is present (including one shipment on a multi-shipment policy). Whole-policy cancel is only allowed before any tracking number exists. Removing a shipment via syncShipments is not a policy cancel.

Returns 409 cancel_blocked_by_claim when the policy has a completed claim or an incomplete claim application.

Events (polling)

GET /open/v1/events

Query parameters

ParameterDescription
sinceISO 8601; events after this time only
type / eventFilter by event type
limitDefault 50

Event types: policy.insured · policy.failed · policy.endorsed · policy.cancelled · claim.submitted · claim.status_changed · claim.completed

Outbound webhooks

Configure in the Merchant portal (/portal/webhook-config), or contact contact@gooval.io.

Gooval POSTs JSON events to your URL. Payloads include policyNo and externalReference — never spNumber. When set at apply, they also include merchantShop and metadata.

Webhook signature header remains X-Gwofy-Signature (legacy naming, matches backend).

Subscribed events

policy.insured · policy.failed · policy.endorsed · policy.cancelled · claim.submitted · claim.status_changed · claim.completed

Verification

X-Gwofy-Signature = hex(hmac_sha256(webhook_secret, timestamp + "." + rawBody))

Postman mapping

Request nameMethodPath
List policies (paginated)GET/open/v1/policies
List collectionsGET/open/v1/reference-data
Get reference dataGET/open/v1/reference-data/claim-types
Cancel reasonsGET/open/v1/reference-data/cancel-reasons
QuotePOST/open/v1/policies/quote
Attachment upload URLPOST/open/v1/policies/attachments/upload-url
ApplyPOST/open/v1/policies/apply
Get documentGET/open/v1/policies/{policyNo}/document
EndorsePOST/open/v1/policies/{policyNo}/endorse
CancelPOST/open/v1/policies/{policyNo}/cancel

Environment example

{
  "baseUrl": "https://sp-prod.gooval.io",
  "OPEN_API_STORE_NUMBER": "100001",
  "OPEN_API_SECRET": "<your-secret>"
}