digiwin_dsp
Ruby client for the Digiwin DSP Self-hosted Website Module (自有官網模組) API. Lets your storefront push orders, cancellations, invoice updates, and returns into the Digiwin ERP through the DSP gateway.
| Operation | Resource | Endpoint |
|---|---|---|
| Create order | DigiwinDsp::Resources::Order |
POST /v1/SalesOrder/add |
| Cancel order | DigiwinDsp::Resources::Cancellation |
POST /v1/SalesOrder/cancel |
| Invoice update | DigiwinDsp::Resources::Invoice |
POST /v1/SalesOrder/invoice |
| Return | DigiwinDsp::Resources::Return |
POST /v1/SalesOrder/return |
| Register webhook | DigiwinDsp::Resources::WebhookSubscription |
POST /v1/webhook (on webhook_base_url) |
| Receive webhook (inbound) | DigiwinDsp::Webhooks.parse |
— |
See docs/dsp-api-spec.md (plus docs/dsp-specs/*.yaml) for the wire spec.
Installation
# Gemfile
gem "digiwin_dsp"
bundle install
Ruby ≥ 3.2 required.
Configuration
Configure once at boot (e.g. config/initializers/digiwin_dsp.rb in Rails):
DigiwinDsp.configure do |c|
c.api_key = ENV.fetch("DIGIWIN_DSP_API_KEY")
c.platform_id = ENV.fetch("DIGIWIN_DSP_PLATFORM_ID")
c.environment = :sandbox # :sandbox (UAT) | :production
c.logger = Rails.logger # any Logger-like object
c.timeout = 10 # request timeout (seconds)
c.open_timeout = 5
end
Every setting also falls back to an ENV var:
| Setting | ENV var | Default | Notes |
|---|---|---|---|
api_key |
DIGIWIN_DSP_API_KEY |
(required) | sent as DSP-api-key header |
platform_id |
DIGIWIN_DSP_PLATFORM_ID |
nil |
sent per-record in request_detail.platform_id (not in auth headers) |
environment |
DIGIWIN_DSP_ENV |
:sandbox |
:sandbox (UAT) or :production |
base_url |
DIGIWIN_DSP_BASE_URL |
resolved from environment |
must be https:// and have a host in allowed_hosts |
webhook_base_url |
DIGIWIN_DSP_WEBHOOK_BASE_URL |
resolved from environment |
for WebhookSubscription; same validation as base_url |
allowed_hosts |
— | ["digiwindsp.digiwin.com"] |
SSRF allowlist; extend if you proxy DSP through a different domain |
timeout |
— | 10 |
seconds |
open_timeout |
— | 5 |
seconds |
logger |
— | Logger.new(IO::NULL) |
any Logger-like object |
Base URLs (resolved from environment):
:sandbox→https://digiwindsp.digiwin.com/DSP_UAT/api/DSP:production→https://digiwindsp.digiwin.com/DSP/api/DSP
See .env.local.example for a starter env file.
Custom proxy host
If you front DSP with a corporate proxy or use a mock server, add the host:
DigiwinDsp.configure do |c|
c.allowed_hosts += ["dsp-proxy.your-co.internal"]
c.base_url = "https://dsp-proxy.your-co.internal/api/DSP"
end
Any host not in allowed_hosts, or any non-https:// URL, raises
DigiwinDsp::ConfigurationError. This is an SSRF + HTTP-downgrade guard
since DIGIWIN_DSP_BASE_URL accepts arbitrary input.
Usage
Each resource exposes a single #create(records, idempotency_key:, digi_header:) method that returns the parsed response_detail array on success or raises a typed exception on failure.
Create an order
record = {
"platform_id" => "acme_storefront_test",
"create_datetime" => Time.now.strftime("%Y-%m-%d %H:%M:%S"),
"site_no" => "acme_storefront_test",
"form_no" => "WEB202605200001", # storefront order number
"order_date" => "20260520",
"buyer_name" => "王小明",
"receiver_name" => "王小明",
"pay_type" => "9104",
"shipping_type" => "9102",
"tax_type" => "1",
"sno" => "1", # line index
"form_subno" => "1",
"product_no" => "P-001",
"product_name" => "測試商品",
"unit" => "EA",
"qty" => "1",
"free_qty" => "0",
"price" => "100",
"subtotal" => "100",
"payment" => "100",
"order_status" => "3", # 3 = new order
"last_record" => "Y" # "Y" on the final line
}
response_detail = DigiwinDsp::Resources::Order.create(record)
# => [{ "form_no" => "WEB202605200001", ... }]
Multi-line orders: pass an array. Each element must carry the order-level fields plus its own line fields. Set "last_record" => "Y" on the final element and "N" on the rest (live-verified against UAT 2026-06-12; the YAML spec says blank means "not last", but the gem's required-field check rejects blanks and DSP accepts the explicit "N"):
records = [
base_fields.merge("sno" => "1", "product_no" => "P-001", "qty" => "1", "last_record" => "N"),
base_fields.merge("sno" => "2", "product_no" => "P-002", "qty" => "3", "last_record" => "Y")
]
DigiwinDsp::Resources::Order.create(records)
Cancel, invoice update, return
DigiwinDsp::Resources::Cancellation.create(cancel_record)
DigiwinDsp::Resources::Invoice.create(invoice_record)
DigiwinDsp::Resources::Return.create(return_record)
Each has its own required-field set (8 / 11 / 19 fields respectively). Inspect REQUIRED_FIELDS for the exact list, e.g.:
DigiwinDsp::Serializers::CancellationSerializer::REQUIRED_FIELDS
⚠️ Invoice sync requires ERP-side customization. Per DSPOOFFICIAL004's spec note (個案), DSP accepts your invoice data unconditionally, but it only becomes visible inside the ERP after Digiwin performs per-customer integration work. If invoices appear to sync successfully but the ERP team can't see them, this is why — confirm the customization with your Digiwin contact before debugging your own code.
order_status enum
Each endpoint requires a specific order_status value inside request_detail. DSP rejects others with WrongStatus:order_status錯誤,請固定給N(...). The OpenAPI examples don't document this — verified live against UAT 2026-05-21:
| Resource | order_status |
Meaning |
|---|---|---|
Resources::Order |
"3" |
新增訂單 (new order) |
Resources::Cancellation |
"2" |
取消訂單 (cancel order) |
Resources::Invoice |
"5" |
發票更新 (invoice update) |
Resources::Return |
"7" |
退貨訂單 (return order) |
Idempotency
DSP only dedupes on form_no + platform_id. Use a deterministic form_no derived from your domain order ID and DSP will reject duplicates with Duplicated:訂單不可重複 (mapped to DuplicateRequestError).
The idempotency_key: kwarg attaches an X-Idempotency-Key request header for logging/tracing on your side, but DSP UAT does not act on it (live-verified 2026-05-22 with two distinct form_no POSTs sharing the same key — both succeeded). Treat the header as a trace ID, not an idempotency guarantee.
DigiwinDsp::Resources::Order.create(record, idempotency_key: "order-#{record['form_no']}")
Webhooks (DSP push events)
Two halves — register a callback URL with DSP, then receive ERP-originated events at that URL.
Register (outbound) — tell DSP where to push notifications for one of three documented actions:
DigiwinDsp::Resources::WebhookSubscription.create(
action: "product/inventory_update", # or "wms/logistics/package/update", "invoice/update"
address: "https://yourshop.example.com/webhooks/dsp/inventory"
)
# => { "platform_id" => ..., "address" => ..., "action" => ... }
platform_id falls back to Configuration#platform_id; prod defaults to "OFFICIALWEBSITE". Each action needs its own subscription call.
Receive (inbound) — parse what DSP POSTs to your callback URL:
# config/routes.rb
post "/webhooks/dsp/inventory", to: "dsp_webhooks#inventory"
# app/controllers/dsp_webhooks_controller.rb
class DspWebhooksController < ActionController::API
def inventory
event = DigiwinDsp::Webhooks::InventoryUpdate.parse(request.raw_post)
# event.platform_id, event.sale_page_id, event.spec_list[] — see docs/dsp-specs/DSPOOFFICIAL100.yaml
DspInventoryUpdateJob.perform_later(event.raw)
head :ok
rescue DigiwinDsp::Webhooks::ParseError => e
Rails.logger.error("DSP webhook parse failed: #{e. || e.}")
head :bad_request
end
end
Or use the dispatcher when one URL handles all 3 actions:
event = DigiwinDsp::Webhooks.parse(request.raw_post, action: params[:action])
case event
when DigiwinDsp::Webhooks::InventoryUpdate then ...
when DigiwinDsp::Webhooks::LogisticsUpdate then event.tracking_number
when DigiwinDsp::Webhooks::InvoiceUpdate then event.invoices.each { |inv| ... }
end
⚠️ DSP does NOT sign inbound webhooks. There is no HMAC header. Defend the callback endpoint with:
- HTTPS-only —
WebhookSubscriptionrejects non-https://addresses at registration time (DSP's own spec mandates HTTPS callbacks)- An unguessable URL path (treat it as a secret)
- An IP allowlist for DSP's egress range if your network team can get one
- Replying
200 OKwithin 30 seconds (DSP will retry and may eventually block your endpoint if too many calls fail)- Idempotency by
form_no/invoice_number/ etc. on your side — DSP may retry the same event
Built-in retry (read before stacking job retries)
Every Client#post already retries transparently inside Faraday:
| Setting | Value |
|---|---|
| Attempts | up to 4 (1 original + max 3 retries) |
| Triggers | HTTP 429, 500, 502, 503, 504, connection failures |
| Backoff | exponential — ~0.5s, ~1s, ~2s between attempts, ±50% jitter |
So one Resources::Order.create call can take up to ~4 × timeout + 3.5s in the worst case (default timeout 10s → ~44s). Size your job timeouts and queue latency budgets accordingly — if you also add retry_on in ActiveJob/Sidekiq (recommended for RateLimitError, which DSP signals via the envelope and the gem does not retry internally), the two layers multiply.
The built-in retry covers transport-level blips; envelope-level "retry later" signals (Processing:資料處理中, SalesNotCreate: etc. → RateLimitError) are deliberately left to your job layer, where you control scheduling.
Background jobs
The gem is synchronous on purpose. Wrap calls in your own job runner:
class SyncOrderToDigiwinJob < ApplicationJob
retry_on DigiwinDsp::RateLimitError, wait: :polynomially_longer, attempts: 5
discard_on DigiwinDsp::DuplicateRequestError
def perform(order_id)
order = Order.find(order_id)
DigiwinDsp::Resources::Order.create(order.to_dsp_payload, idempotency_key: "order-#{order.id}")
end
end
Error handling
All exceptions inherit from DigiwinDsp::Error and carry structured attributes safe for logging: #code, #dsp_message, #http_status, #request_id. The raw response body is intentionally not exposed on exceptions to prevent PII leakage to error reporters like Sentry/Rollbar that serialize exception instance variables.
| Exception | Raised when |
|---|---|
DigiwinDsp::ConfigurationError |
api_key missing at request time |
DigiwinDsp::ValidationError |
Required field missing locally, or DSP returns WrongStatus: / Processing:取消訂單處理中 / HTTP 400 |
DigiwinDsp::AuthenticationError |
HTTP 401 / 403 |
DigiwinDsp::DuplicateRequestError |
DSP returns Duplicated: or HTTP 409 |
DigiwinDsp::RateLimitError |
DSP returns Processing:資料處理中 (retryable) or persistent HTTP 429 |
DigiwinDsp::ServerError |
DSP returns 系統異常: or HTTP 5xx that exhausts retries |
DigiwinDsp::NetworkError |
TCP connect failure or timeout |
DigiwinDsp::Error |
Catch-all (unmapped failure message or unexpected status) |
⚠️ Digiwin DSP returns HTTP 200 even on application-level failure. The gem parses the response body's
Status/Messagefields and raises the appropriate typed exception so callers can rescue normally.
begin
DigiwinDsp::Resources::Order.create(record)
rescue DigiwinDsp::DuplicateRequestError
Rails.logger.info("Order already pushed, skipping")
rescue DigiwinDsp::ValidationError => e
Rails.logger.error("Payload rejected: #{e.}")
raise
rescue DigiwinDsp::RateLimitError, DigiwinDsp::ServerError
raise # let the job retry
end
Troubleshooting / FAQ
"My request succeeded with HTTP 200 but raised an exception?"
That's DSP's design — application failures come back as HTTP 200 with Status:"Failure" in the body. The gem parses the envelope and raises the matching typed exception. Trust the exception, not the HTTP status.
AuthenticationError: DSP 序號驗證失敗
Your DSP-api-key is wrong, expired, or for the other environment (UAT keys don't work on production and vice versa). Note this arrives as HTTP 200, not 401.
RateLimitError: ...SalesNotCreate:銷貨單未成立 (invoice sync)
The ERP hasn't converted the order into a sales document yet — this is a timing issue, not a bug. Retry later (the exception type is retryable by design). If it persists for hours, ask your Digiwin contact whether order conversion is running.
ValidationError: ...Shipped:訂單已出貨,不可取消 (cancel)
Permanent — the order left the warehouse. Don't retry; surface to your support flow instead.
ValidationError: ...WrongStatus:order_status錯誤,請固定給N(...)
You sent the wrong order_status for that endpoint. DSP's message tells you the expected value; or just use DigiwinDsp::Enums::OrderStatus constants.
DuplicateRequestError on a brand-new order
DSP dedupes on form_no + platform_id forever — including orders created in earlier tests. Generate unique form_no values per environment.
Inventory webhook never arrives
Webhook delivery requires (1) a successful WebhookSubscription.create for that exact action, (2) an HTTPS endpoint answering 200 within 30s, and (3) the ERP actually emitting the event. Check all three, in that order.
Invoices sync but the ERP team can't see them See the invoice caveat above — DSPOOFFICIAL004 requires per-customer ERP customization (個案) before invoice data is visible in the ERP.
Custom digi_header
By default the gem omits digi_header from the request body (it's only required for certain custom Digiwin integrations). If your DSP setup expects one, pass it through:
DigiwinDsp::Resources::Order.create(
record,
digi_header: {
"digi_host" => { "prod" => "EC-SHOP", "ip" => "10.0.0.42", "timestamp" => "20260520123456789" },
"digi_service" => { "prod" => "ECP", "name" => "salesorder.add" }
}
)
Development
bin/setup # bundle install
bundle exec rspec # run the full test suite (134 examples, 100% coverage)
bundle exec rubocop # lint
bin/console # IRB with the gem loaded
The full DSP OpenAPI 3.1 specs live under docs/dsp-specs/. If Digiwin updates them, replace the YAML files and re-run the test suite — the REQUIRED_FIELDS constants in each serializer pin the contract.
Contributing
- Fork & branch
bin/setup- Write tests first (TDD); ensure
bundle exec rspecandbundle exec rubocopare green - Open a PR
License
MIT. See LICENSE.txt.