Base URL
https://app-sandbox.gutline.eu/api/v1

海关申报 API 提供申报管理功能,包括创建、确认和取消申报。

重要说明:申报一旦提交到海关系统就不能修改,如需修改请重新创建申报。

认证

参考 Bearer token 认证方式

幂等请求

API 支持幂等请求,您可以安全的重试请求,而不会意外的执行两次相同操作,要执行一个幂等请求须在请求头中增加:

Idempotency-Key: <key> 重试时保证 key 不变

幂等请求仅支持使用 POST 方式的请求,其他方式的请求将被忽略

分页

分页按照 rfc5988 标准

import requests

response = requests.get('https://app-sandbox.gutline.eu/api/v1/declarations', headers={'Authorization': 'Bearer token'})
next_page_url = response.links['next']['url']
response = requests.get(next_page_url, headers={'Authorization': 'Bearer token'})

请求限制

  • X-RateLimit-Limit 每小时请求次数限制

  • X-RateLimit-Remaining 剩余请求次数

  • X-RateLimit-Reset 下次重置时间 UTC

Webhook 通知

当申报状态发生变化时,我们会向您配置的 webhook 端点发送 HTTP POST 请求。

事件类型

事件类型 触发时机 描述
declaration.updated 申报状态更新 当申报状态发生变化时触发

请求格式

HTTP Headers
Content-Type: application/json
X-Webhook-Event: declaration.updated
X-Webhook-Signature: sha256=abc123...
X-Webhook-Delivery: jid-123456
User-Agent: Gutline-Hookshot/0.1

注意:签名格式为 算法=签名值,如 sha256=abc123...,其中 sha256 是算法,abc123... 是签名值。

请求体结构
{
  "event": "declaration.updated",
  "payload": {
    "object": "declaration",
    "id": 222537,
    "reference": "DECL_20251020_021323_65558849",
    "template_code": "NLAMS_H7",
    "client_ref": "NLH7-1727866234",
    "lrn": "NLH7-1727866234",
    "mrn": "25NLNG746OHK9FBER0",
    "declaration_type": "IM",
    "additional_declaration_type": "A",
    "declaration_office_code": "NL000567",
    "presentation_office_code": null,
    "shipment_carrier_ref": null,
    "mode_at_border": "air",
    "inland_mode": null,
    "country_of_dispatch_code": "CN",
    "country_of_destination_code": "NL",
    "gross_weight_in_kg": "0.5",
    "total_packages": 10,
    "unique_consignment_reference": null,
    "location_of_goods_code": null,
    "location_of_goods_type": null,
    "delivery_terms": null,
    "delivery_terms_location": null,
    "currency_code": "EUR",
    "currency_exchange_rate": null,
    "ioss_number": "IM9000000001",
    "deferred_payment_account": null,
    "status": "released",
    "reject_reason": null,
    "provisional_validation_datetime": "2025-10-01 12:00:00",
    "total_intrinsic_value": "24.0",
    "total_freight_cost": "0.0",
    "total_insurance_cost": "0.0",
    "total_invoice_value": "24.0",
    "cancellation_requested_at": null,
    "cancelled_at": null,
    "submitted_at": null,
    "rejected_at": null,
    "accepted_at": "2025-10-20T02:13:47.278Z",
    "inspected_at": null,
    "released_at": "2025-10-20T02:13:47.325Z",
    "archived_at": null,
    "goods_presentation_effective_date": "2025-10-20",
    "goods_presentation_due_date": "2025-11-19",
    "importer": {
      "eori_number": "DE123456789",
      "name": "Import Company B.V.",
      "attention_to": "Hans Michaël",
      "street_address": "Beëk Street 1",
      "city": "Rotterdam",
      "postal_code": "3000 AA",
      "state": null,
      "country_code": "NL",
      "email": "hans@test.com",
      "phone": "+49 30 123456",
      "vat_number": null
    },
    "exporter": {
      "eori_number": null,
      "name": "Shanghai Exports Ltd",
      "attention_to": null,
      "street_address": "Seller Street 1",
      "city": "Shanghai",
      "postal_code": "200000",
      "state": null,
      "country_code": "CN",
      "email": null,
      "phone": null
    },
    "items": [
      {
        "id": 221436,
        "description": "T-shirts",
        "commodity_code": "6109100010",
        "national_additional_codes": [],
        "requested_procedure": null,
        "additional_procedure_codes": [
          "F48",
          "C07"
        ],
        "currency_code": "EUR",
        "gross_weight_in_grams": 500,
        "net_weight_in_grams": 450,
        "package_type": null,
        "number_of_packages": 1,
        "shipping_marks": null,
        "quantity": 2,
        "supplementary_units": null,
        "supplementary_units_amount": null,
        "country_of_origin": "CN",
        "dangerous_goods_code": null,
        "ecommerce_ref": null,
        "ecommerce_url": null,
        "created_at": "2025-10-20T02:13:23.425Z",
        "updated_at": "2025-10-20T02:13:23.425Z",
        "intrinsic_value": "24.0",
        "freight_cost": null,
        "insurance_cost": null,
        "customs_value": "24.0",
        "documents": [],
        "tax_lines": [
          {
            "id": 97,
            "type_code": "B00",
            "category": "vat",
            "regime_code": null,
            "rate": "0.0",
            "currency": "EUR",
            "base_amount": "24.0",
            "deduct_amount": "0.0",
            "amount": "0.0"
          }
        ]
      }
    ],
    "documents": [
      {
        "id": 478104,
        "category": "supporting",
        "type_code": "N380",
        "reference_number": "INV-SH-2025-001",
        "valid_from": "2025-10-01T00:00:00.000Z",
        "file_url": null,
        "type_description": "Commercial invoice"
      },
      {
        "id": 478105,
        "category": "transport",
        "type_code": "N740",
        "reference_number": "WBL12345678",
        "valid_from": "2025-10-01T00:00:00.000Z",
        "file_url": null,
        "type_description": "Air Waybill"
      }
    ],
    "tax_lines": [
      {
        "id": 98,
        "type_code": "B00",
        "category": "vat",
        "regime_code": null,
        "rate": "0.0",
        "currency": "EUR",
        "base_amount": "24.0",
        "deduct_amount": "0.0",
        "amount": "0.0"
      }
    ],
    "created_at": "2025-10-20T02:13:23.408Z",
    "updated_at": "2025-10-20T02:13:47.354Z",
    "url": "https://app-sandbox.gutline.eu/api/v1/declarations/NLH7-1727866234"
  }
}

安全验证

我们使用 HMAC 算法对 webhook payload 进行签名验证,支持多种哈希算法(如 SHA256)。

设计理念:使用 算法=签名值 的格式,使得我们可以在后期升级到更安全的算法时,所有客户端都能自动适配,无需修改代码。这确保了系统的向前兼容性和安全性。

# Ruby 示例
def verify_webhook_signature(payload, signature, secret)
  algorithm, signature_value = signature.split('=', 2)
  expected_signature = OpenSSL::HMAC.hexdigest(algorithm, secret, payload)
  Rack::Utils.secure_compare(signature_value, expected_signature)
end

# 使用示例 - 注意 payload 是 raw body
def handle_webhook
  payload = request.body.read  # 获取 raw body
  signature = request.headers['X-Webhook-Signature']
  secret = ENV['WEBHOOK_SECRET']

  unless verify_webhook_signature(payload, signature, secret)
    return head :unauthorized
  end

  # 解析 JSON
  event_data = JSON.parse(payload)

  # 处理事件
  begin
    process_webhook_event(event_data)
    head :ok  # 处理成功返回 200
  rescue => e
    Rails.logger.error "Webhook processing failed: #{e.message}"
    head :internal_server_error  # 处理失败返回 500,触发重试
  end
end
# Python 示例
import hmac
import hashlib
import json

def verify_webhook_signature(payload, signature, secret):
    algorithm, signature_value = signature.split('=', 1)
    expected_signature = hmac.new(
        secret.encode(), payload.encode(), getattr(hashlib, algorithm)
    ).hexdigest()
    return hmac.compare_digest(signature_value, expected_signature)

# 使用示例 - 注意 payload 是 raw body
@app.route('/webhooks', methods=['POST'])
def handle_webhook():
    payload = request.get_data()  # 获取 raw body
    signature = request.headers.get('X-Webhook-Signature')
    secret = os.environ.get('WEBHOOK_SECRET')

    if not verify_webhook_signature(payload, signature, secret):
        return 'Unauthorized', 401

    try:
        # 解析 JSON
        event_data = json.loads(payload)

        # 处理事件
        process_webhook_event(event_data)
        return 'OK', 200  # 处理成功返回 200
    except Exception as e:
        print(f"Webhook processing failed: {e}")
        return 'Internal Server Error', 500  # 处理失败返回 500,触发重试
// Node.js 示例
const crypto = require('crypto');

function verifyWebhookSignature(payload, signature, secret) {
  const [algorithm, signatureValue] = signature.split('=');
  const expectedSignature = crypto
    .createHmac(algorithm, secret)
    .update(payload)
    .digest('hex');

  return crypto.timingSafeEqual(
    Buffer.from(signatureValue),
    Buffer.from(expectedSignature)
  );
}

// 使用示例 - 注意 payload 是 raw body
app.post('/webhooks', (req, res) => {
  const payload = JSON.stringify(req.body);  // 获取 raw body
  const signature = req.headers['x-webhook-signature'];
  const secret = process.env.WEBHOOK_SECRET;

  if (!verifyWebhookSignature(payload, signature, secret)) {
    return res.status(401).send('Unauthorized');
  }

  try {
    // 解析 JSON
    const eventData = JSON.parse(payload);

    // 处理事件
    processWebhookEvent(eventData);
    res.status(200).send('OK');  // 处理成功返回 200
  } catch (error) {
    console.error('Webhook processing failed:', error);
    res.status(500).send('Internal Server Error');  // 处理失败返回 500,触发重试
  }
});

重试机制

  • 最多重试 5 次:如果您的端点返回非 2xx 状态码,我们会重试
  • 重试间隔:120 秒
  • 失败处理:连续失败 50 次后,webhook 会被暂停
  • 自动禁用:暂停 3 天后,webhook 会被自动禁用
  • 建议:为了确保事件不丢失,建议在事件处理完全成功后再返回 200 状态码
  • 数据更新:每次重试都会发送最新的数据,状态可能已经发生变化
  • 设计理念:避免存储大量过期数据,过期数据没有意义,建议使用时间戳判断状态变化

响应要求

  • 状态码:必须返回 2xx 状态码表示成功
  • 响应时间:建议在 30 秒内响应
  • 响应格式:建议返回 JSON 格式响应
  • 重要提醒:建议确保事件处理完全成功后再返回 200 状态码,以避免事件丢失

事件状态说明

申报状态流转
pending → submitted → acknowledged → accepted → released

状态流转图

正常申报流程:
pending ──→ submitted ──→ accepted ──→ released
                │
                ▼
            rejected

预申报流程:
pending ──→ submitted ──→ acknowledged ──→ accepted ──→ released
                │
                ▼
            rejected

说明

  • 申报从 pending 状态开始
  • acknowledged 状态仅用于预申报
  • 正常申报流程:pending → submitted → accepted → released
  • 预申报流程:pending → submitted → acknowledged → accepted → released
  • 任何状态都可能被 rejectedcancelled
异常状态
submitted → rejected (海关拒绝)
submitted → cancelled (取消)
重要提醒

重试时数据会更新:每次重试都会发送最新的数据,状态可能已经发生变化。例如:

  • 第一次通知:status: "accepted"
  • 重试通知:status: "released"

建议使用时间戳判断:不要根据状态字段判断,而是根据对应的时间戳字段(如 accepted_at, released_at)来判断是否发生了新的状态变化。这些时间戳字段只会设置一次,不会更新,能准确判断是否发生了新的状态变化。这种设计避免了存储大量过期数据,过期数据也没有意义。

常见问题

Q: 重试时数据会变化吗?

A: 是的,每次重试都会发送最新的数据。如果第一次通知时状态是 accepted,重试时可能已经是 released。这是正常的设计,确保接收方总是获得最新状态。

Q: 如何处理状态变化?

A: 建议使用关键时间戳字段来判断是否发生了新的状态变化。主要关注以下字段:

关键时间戳字段

  • submitted_at - 提交时间
  • accepted_at - 接受时间
  • inspected_at - 查验时间
  • released_at - 放行时间
  • cancelled_at - 取消时间
  • rejected_at - 拒绝时间

关键业务字段

  • mrn - 海关参考号
  • reject_reason - 拒绝原因

处理示例

# 检查是否被接受
if payload['accepted_at'] && !last_processed_accepted_at
  # 处理接受逻辑
  last_processed_accepted_at = payload['accepted_at']
end

# 检查是否被放行
if payload['released_at'] && !last_processed_released_at
  # 处理放行逻辑
  last_processed_released_at = payload['released_at']
end

Q: 为什么使用时间戳而不是状态?

A: 这种设计有两个重要优势:

  1. 避免存储压力:不需要存储大量过期数据
  2. 过期数据无意义:过期数据对业务没有价值
  3. 精确判断:时间戳字段(如 accepted_atreleased_at)只会设置一次,能准确判断是否发生了新的状态变化

Q: 如何处理大量事件?

A: 建议异步处理事件,快速返回 2xx 状态码。

Q: 如何获取 webhook 密钥?

A: 在创建 webhook 时,系统会自动生成密钥,请妥善保存。

Q: 如何测试 webhook 配置?

A: 使用 Dashboard 中的 ping 功能发送测试请求。

Q: 为什么使用 算法=签名值 的格式?

A: 这种格式允许我们在后端升级到更安全的算法时,所有客户端都能自动适配,无需修改代码。这确保了系统的向前兼容性和安全性。

Q: 支持哪些签名算法?

A: 我们目前使用 SHA256 算法生成签名。随着时间推移和算力提升,我们会主动升级到更安全的算法(如 SHA384、SHA512 等)。您的代码应支持动态解析签名头中的算法名称,以便在我们升级算法时无需修改代码即可兼容。

Q: 为什么建议处理完成后才返回 200?

A: 返回 200 状态码表示处理成功,我们不会重试该事件。为了确保事件不丢失,建议在业务逻辑完全处理成功后再返回 200 状态码。

Q: payload 是原始字符串还是解析后的对象?

A: payload 是原始的 JSON 字符串(raw body),建议先解析为对象再使用。签名验证需要使用原始字符串。

荷兰海关申报指南

实体 字段 类型 DECO (F48) DECO (F49) DMS 描述
Declaration declaration_type string IM
Declaration additional_declaration_type string D D A A: 标准申报 D: 预申报
Declaration declaration_office_code string NL000567
Declaration country_of_dispatch_code string CN
Declaration country_of_destination_code string NL
Declaration mode_at_border string x x sea,rail,road,air
Declaration ioss_number string x x IM9000000001
Declaration location_of_goods_country_code string NL
Declaration location_of_goods_postal_code string 1118 CP
Declaration location_of_goods_street_number string 1
Declaration delivery_terms string x x DDP, DDU
Declaration delivery_terms_location string x x NLAMS (UN/LOCODE)
Declaration total_invoice_value decimal x x 12.34
Declaration currency_code string x x EUR
Declaration documents []object N380, N740
Item additional_procedure_codes []string ["F48", "C07"] ["F49", "C07"] x ["F48", "C07"]
Item national_additional_codes []string x x ["Y160"]

location_of_goods

这个根据各个国家要求不同会相对复杂,所以支持两种写法:

{
  "location_of_goods": {
    "country_code": "NL",
    "postal_code": "1118 CP",
    "street_number": "1"
  }
}
{
  "location_of_goods_country_code": "NL",
  "location_of_goods_postal_code": "1118 CP",
  "location_of_goods_street_number": "1"
}

This is version 1.0.0 of this API documentation. Last update on Dec 5, 2025.