Webhooks
Stay informed about important events in your Duro library with real-time webhook notifications.
Webhooks follow a "Ping then Pull" pattern - you receive lightweight event notifications with key metadata, then fetch full resource details using our GraphQL API when needed.
Overview
Duro webhooks let your applications receive real-time notifications when important events occur in your library. Instead of constantly polling for changes, webhooks push event notifications directly to your specified endpoints, helping you build responsive integrations that react immediately to data changes.
Key Benefits
Real-time updates - Get notified instantly when data changes
Efficient integration - No need for constant API polling
Selective subscriptions - Subscribe only to the events you care about
Reliable delivery - Built-in retry mechanisms with exponential backoff
Secure - HMAC-SHA256 signature verification to ensure authenticity
How Webhooks Work
Event occurs - Something happens in Duro (e.g., a component is updated)
Notification sent - Duro sends a lightweight JSON payload to your webhook URL
Fetch full data - Your application uses the provided metadata to fetch complete details via GraphQL as needed
This pattern keeps webhook payloads small and fast while giving you access to all the data you need.
Available Events
Webhooks are scoped to a specific library. Each webhook can subscribe to one or more event types.
Component Events
These events fire when components in your library are created, updated, or deleted:
COMPONENT_CREATED
components.created
A new component was created in the library
COMPONENT_UPDATED
components.updated
An existing component was modified
COMPONENT_DELETED
components.deleted
A component was removed from the library
When subscribing to events via GraphQL, use the enum name (e.g., COMPONENT_CREATED). When processing webhook payloads, the event field contains the string value (e.g., components.created).
Change Order Events
Track the full lifecycle of change orders in your library with these events:
CHANGE_ORDER_OPENED
change_orders.opened
A change order transitioned from draft to open status
CHANGE_ORDER_UPDATED
change_orders.updated
Change order details were modified (name, description, etc.)
CHANGE_ORDER_DELETED
change_orders.deleted
A change order was archived
CHANGE_ORDER_STAGE_TRANSITION
change_orders.stage_transition
Change order moved to a different workflow stage
CHANGE_ORDER_STAGE_REVIEWER_DECISION
change_orders.stage_reviewer_decision
A reviewer approved or rejected their stage
CHANGE_ORDER_RESOLUTION
change_orders.resolution
Change order was fully approved, rejected, or withdrawn
CHANGE_ORDER_CLOSED
change_orders.closed
A change order transitioned into the terminal closed state after resolution
Semantic Event Priority: When a change triggers multiple events (e.g., updating status from draft to open), Duro sends the more specific semantic event (CHANGE_ORDER_OPENED) rather than the generic CHANGE_ORDER_UPDATED. This prevents duplicate notifications and makes event handling more predictable.
Setting Up Webhooks
Create a Webhook
To create a webhook, you'll need:
The
x-libraryheader set to specify which library to monitorA
url- your HTTPS endpoint that will receive notificationsA list of
events- which event types to subscribe to
Required Headers: All webhook operations require the x-api-key, x-organization, and x-library headers. The webhook will be created for the library specified in the x-library header.
Configuration Options
name
Yes
Unique name for this webhook (1-100 characters)
-
url
Yes
HTTPS endpoint URL (must be valid HTTPS)
-
events
Yes
Array of event types to subscribe to
-
description
No
Optional description (max 500 characters)
null
signingSecret
No
Secret for HMAC signature verification (min 16 characters if provided)
null
timeoutSeconds
No
Request timeout in seconds (5-300)
30
maxRetries
No
Maximum retry attempts on failure (0-10)
3
isEnabled
No
Whether the webhook is active
true
isArchived
Read-only
Whether the webhook has been archived (set via archive mutation)
false
List Your Webhooks
Retrieve all webhooks for the library specified in your x-library header:
The findAll query returns webhooks for the library specified in the x-library header. Only active (non-archived) webhooks are returned.
Get a Specific Webhook
The findOne query returns a webhook_not_found error if the webhook has been archived.
Update a Webhook
You can update any webhook configuration field:
Add Events to a Webhook
Add additional event subscriptions without removing existing ones:
Remove Events from a Webhook
Remove specific event subscriptions:
Archive a Webhook
When you no longer need a webhook but want to preserve its configuration history, you can archive it instead of deleting it. Archived webhooks:
Stop receiving events - No new event deliveries will be attempted
Are hidden from queries - Won't appear in
findAllorfindOneresultsFree up the name - You can create a new webhook with the same name
Cannot be unarchived - This action is permanent
Archiving a webhook is permanent and cannot be undone. If you need to temporarily stop webhook deliveries, consider using the update mutation to set isEnabled: false instead.
When to Archive vs. Disable
Disable (isEnabled: false)
Temporarily pause deliveries; webhook remains queryable and can be re-enabled
Archive
Permanently retire a webhook; frees up the name for reuse
Webhook Payloads
All webhook notifications follow a consistent JSON structure:
Payload Fields Explained
event
string
The event type (e.g., components.created)
eventId
string
Unique identifier for this webhook delivery (UUID)
sourceId
string
Internal event ID for tracking and debugging
timestamp
string
ISO 8601 timestamp when the event occurred
metadata
object
Event-specific data (see below)
Component Event Metadata
For component events, the metadata object contains:
componentId
string
UUID of the affected component
revisionValue
string
Current revision value (e.g., "1.A", "2.B")
version
number
Current version number
Example: Component Created
Example: Component Updated
Change Order Event Metadata
Each change order event type includes different metadata fields based on the event context.
change_orders.opened
change_orders.openedFired when a change order transitions from draft to open status (submitted for review).
changeOrderId
string
UUID of the change order
status
string
New status value (open)
change_orders.updated
change_orders.updatedFired when change order details are modified (name, description, etc.) without triggering a semantic event like opened or resolution.
changeOrderId
string
UUID of the change order
change_orders.deleted
change_orders.deletedFired when a change order is archived.
changeOrderId
string
UUID of the change order
change_orders.stage_transition
change_orders.stage_transitionFired when a change order moves between workflow stages.
changeOrderId
string
UUID of the change order
previousStageId
string
UUID of the previous stage (omitted if starting workflow)
newStageId
string
UUID of the new stage (omitted if resolved/completed)
transitionReason
string
Reason for the transition (e.g., stage_approved, stage_rejected)
change_orders.stage_reviewer_decision
change_orders.stage_reviewer_decisionFired when a reviewer makes a decision (approve/reject) on their assigned stage.
changeOrderId
string
UUID of the change order
stageId
string
UUID of the stage being reviewed
reviewerId
string
UUID of the reviewer who made the decision
decision
string
The decision made (approved or rejected)
change_orders.resolution
change_orders.resolutionFired when a change order reaches a final resolution (approved, rejected, or withdrawn).
changeOrderId
string
UUID of the change order
resolution
string
Final resolution (approved, rejected, or withdrawn)
change_orders.closed
change_orders.closedFired when a change order transitions into the terminal closed state after it has been resolved. This is distinct from change_orders.resolution: resolution records the decision (approved/rejected/withdrawn), while closed marks the change order moving into its final closed status. Both events fire for a given close.
changeOrderId
string
UUID of the change order
sequentialId
number
Human-facing sequential number of the change order (e.g. 2 for CO-2)
libraryId
string
UUID of the library the change order belongs to
status
string
Always closed for this event
resolution
string
Resolution that preceded the close (approved, rejected, or withdrawn)
resolvedAt
string
ISO 8601 timestamp of when the change order was resolved
closedAt
string
ISO 8601 timestamp of when the change order was closed
closedByUserId
string
UUID of the user who closed the change order
HTTP Headers
Each webhook request includes these headers:
Content-Type
application/json
User-Agent
Duro-Webhook-Service/1.0
X-Webhook-Signature
Versioned HMAC-SHA256 signature in the form sha256=<hex digest> (only sent when a signingSecret is configured on the webhook)
Signing Secrets
Every Duro webhook can carry a signing secret that lets your endpoint cryptographically verify each delivery actually came from Duro and was not modified in transit. Treat the signing secret like any other credential — it is the only thing protecting your endpoint from spoofed requests.
How Signing Works
When a webhook has a signingSecret, Duro signs every outbound delivery as follows:
Compute
HMAC_SHA256(rawRequestBody, signingSecret)and hex-encode the digest.Send the result in the
X-Webhook-Signatureheader, prefixed with the algorithm version.
The header value uses a versioned, Stripe-style format:
The sha256= prefix is reserved for future algorithm upgrades — your verification code should split on = and reject any prefix it does not understand rather than assume a bare hex digest.
The signature is computed over the exact bytes of the request body that Duro sent. Re-serializing the JSON (for example by parsing and re-stringifying it) will produce a different signature and verification will fail. Always verify against the raw body.
Providing a Signing Secret
The signingSecret field is optional on webhooks.create and webhooks.update:
If you provide a
signingSecret, Duro stores the value you provided (minimum 16 characters).If you omit
signingSecreton create, Duro generates a cryptographically secure signing secret for you by default. You can read it back via thefindOnequery.If a webhook has no signing secret configured at all, deliveries are sent unsigned and the
X-Webhook-Signatureheader is omitted.
Because Duro auto-generates a signing secret on create, in practice every new webhook is signed unless you have deliberately cleared the secret.
Verifying Signatures
Your endpoint should:
Read the raw request body bytes before any JSON parsing.
Read the
X-Webhook-Signatureheader and split it into algorithm + digest on the first=.Compute
HMAC_SHA256(rawBody, signingSecret)and hex-encode it.Compare the computed digest to the digest from the header using a timing-safe comparison.
Reject the request if the header is missing, the algorithm is unknown, or the digests do not match.
Verifying Signatures (Node.js)
Verifying Signatures (Python)
Endpoint Hardening Checklist
Reject unsigned requests when you expect signatures. If your webhook has a signing secret configured, treat a missing
X-Webhook-Signatureheader as an authentication failure (401).Verify against the raw body. Parsing JSON before verifying will change the byte sequence and break the signature.
Use a constant-time comparison. Use
crypto.timingSafeEqual(Node) orhmac.compare_digest(Python) — never===/==.Pin the algorithm. Refuse any header that does not start with
sha256=so a future algorithm migration is explicit.Dedupe with
eventId. Signature verification proves authenticity, not uniqueness — combine it with idempotent processing keyed oneventId.
Security Best Practices
Always use HTTPS - Webhook URLs must use HTTPS (enforced by Duro)
Verify signatures - Always validate the
X-Webhook-SignatureheaderUse timing-safe comparison - Prevent timing attacks when comparing signatures
Keep secrets secure - Store your
signingSecretin environment variables, never in codeRotate secrets periodically - Update your signing secret via
webhooks.updateregularly
Fetching Full Resource Data
After receiving a webhook notification, use the provided IDs to fetch complete resource details via GraphQL.
Fetch Component Details
Fetch Change Order Details
Example: Complete Webhook Handler
Here's a complete example showing how to receive a webhook and fetch the full component data:
Retry Logic & Error Handling
Duro automatically retries failed webhook deliveries using exponential backoff.
Retry Schedule
A delivery is retried up to maxRetries times (the webhook's configured value, 0–10, default 3) after the initial attempt. Each attempt is given timeoutSeconds + 10 to respond before it counts as a timeout.
Delays grow exponentially (2× multiplier) starting at 60 seconds, capped at 5 minutes:
1st retry
60 seconds
2nd retry
120 seconds (2 min)
3rd retry
240 seconds (4 min)
4th retry
300 seconds (5 min — cap)
5th–10th retry
300 seconds (5 min — cap)
With the default maxRetries: 3, a failing delivery is attempted 4 times total (1 initial + 3 retries) over roughly 7 minutes of backoff before the log is marked FAILED.
Not every failure is retried. If the webhook is disabled or archived (or its log row no longer exists), the delivery fails immediately and does not consume retries.
What Counts as Success?
Success: HTTP status codes 200-299
Failure: All other status codes, timeouts, or connection errors
Your Endpoint Should
Respond quickly - Return a 200 status code immediately, then process asynchronously
Handle duplicates - Use
eventIdfor idempotency; you may receive the same event more than onceLog failures - Track and investigate webhook processing failures
Be available - Ensure your endpoint is highly available to receive webhooks
Example: Idempotent Processing
Monitoring Webhook Activity
Every delivery attempt is recorded as a webhook log. Use the filterLogs query to monitor delivery health, build dashboards, audit activity over a time window, or drive reprocessing workflows.
filterLogs replaces the deprecated getLogs query. getLogs returned an unpaginated array for a single webhook; filterLogs adds cursor pagination, multi-condition filtering (and / or), and the ability to query across every webhook in your library at once. getLogs will be removed in a future release — migrate to filterLogs.
The filterLogs query
filterLogs queryfilterLogs returns a Relay-style connection: a page of edges (each wrapping a WebhookLog node and its pagination cursor), a pageInfo block, and a totalCount of all logs matching your filter.
Filter input structure
The input is a WebhookLogFilterInput:
and
[WebhookLogFilterConditionInput!]
Conditions that must all match (logical AND)
or
[WebhookLogFilterConditionInput!]
Conditions where any may match (logical OR)
pagination
PaginationInput
Page size, cursor, and sort order
Each entry in and / or is a WebhookLogFilterConditionInput:
webhookId
UUIDFilterInput
The webhook a log belongs to
createdAt
DateFilterInput
When the log row was created (event received)
updatedAt
DateFilterInput
When the log row was last modified (last delivery attempt / processing update)
isAcknowledged
BooleanFilterInput
Whether your endpoint returned a 2xx (true = delivered)
processedState
WebhookLogProcessedStateFilterInput
Your integration's processing outcome (see markProcessed)
status and event are returned on the log node but are not filterable server-side. To surface undelivered logs, filter on isAcknowledged: { eq: false } (matches PENDING and FAILED); to narrow by event type, return event and filter client-side.
Filter operators
processedState uses standard SQL three-valued logic: neq: FAIL excludes both FAIL rows and unprocessed (null) rows. To match "anything that isn't FAIL, including not-yet-processed," combine neq: FAIL with isNull: true in an or.
Pagination
PaginationInput is cursor-based and forward-only:
first
Int
Maximum number of logs to return in this page
after
String
Return logs after this cursor — pass the previous page's pageInfo.endCursor
orderBy
GenericOrderInput
{ field: String!, direction: ASC | DESC } — e.g. { field: "createdAt", direction: DESC }
Walk through all pages by repeating the query with after: <endCursor> until pageInfo.hasNextPage is false. hasPreviousPage is always false in v1.
The webhook log node
A WebhookLog carries three groups of fields:
Delivery (managed by Duro — did we reach your endpoint?)
status
WebhookStatus!
PENDING, DELIVERED, FAILED (RETRYING is legacy and no longer written)
isAcknowledged
Boolean!
true once your endpoint returns a 2xx
responseCode
Float
HTTP status code returned by your endpoint
responseTimeMs
Float
Round-trip time of the delivery request
attemptCount
Float!
Number of delivery attempts made
deliveryErrorMessage
String
Last delivery error (timeout, connection error, non-2xx)
nextRetryAt
DateTime
When the next retry is scheduled, if retrying
Processing (managed by you via markProcessed)
processedState
WebhookLogProcessedState
SUCCESS, FAIL, or null (not yet processed)
processedAt
DateTime
When you reported the processing outcome
processingErrorMessage
String
Failure reason you supplied when reporting FAIL
Identity & content
id
ID!
Log ID — pass this to markProcessed
webhookId
ID!
The webhook that produced this log
event
String!
Event type (e.g. components.created)
payload
JSONObject!
The exact payload delivered to your endpoint
createdAt / updatedAt
DateTime!
Row timestamps
The errorMessage field is deprecated — it is an alias for deliveryErrorMessage. Use deliveryErrorMessage going forward.
Examples
Find undelivered logs across all your webhooks (anything that hasn't received a 2xx):
Audit a reporting window (all logs for one webhook in May 2026):
Find a backlog of unprocessed deliveries (delivered to you, but your integration hasn't reported an outcome — pairs with markProcessed):
Find deliveries your integration failed to process (for reprocessing / dead-letter handling):
Example: Monitoring Script
Tracking Processing Outcomes with markProcessed
markProcessedA log's status (DELIVERED / FAILED) reflects whether Duro successfully delivered the HTTP POST to your endpoint and received a 2xx. It says nothing about whether your integration successfully processed the event afterward — your ERP sync might reject the part, your job queue might fail, your downstream API might be down.
markProcessed lets you record that application-side outcome back onto the webhook log as a processedState (SUCCESS or FAIL), with an optional failure reason.
markProcessed is optional — webhooks work exactly the same whether or not you call it. Its value is that it pairs with filterLogs: once you report processing outcomes, you can query for unprocessed or failed-processing logs to build reconciliation, retry, and dead-letter workflows entirely off Duro's log store — no separate tracking database required.
Reporting a successful outcome
Reporting a failure
Supply a processingErrorMessage so the reason is visible in later filterLogs queries:
Input fields (WebhookMarkProcessedInput)
WebhookMarkProcessedInput)id
Yes
The WebhookLog id to mark
state
Yes
SUCCESS or FAIL
processingErrorMessage
No
Failure reason when state: FAIL. Persisted on the log; capped at 4000 characters — truncate stack traces client-side
Delivery status vs. processed state
These are two independent dimensions of a single log:
Delivery
Duro
status, isAcknowledged, responseCode
Did Duro reach your endpoint and get a 2xx?
Processing
You (markProcessed)
processedState, processedAt, processingErrorMessage
Did your integration successfully handle the event?
A log can be DELIVERED (Duro's POST succeeded) yet processedState: FAIL (your ERP sync threw) — that combination is exactly what reconciliation workflows look for.
Use cases
Reconciliation backlog — report
SUCCESSafter each event is fully handled, then periodically queryfilterLogs(processedState: { isNull: true }, isAcknowledged: { eq: true })to find delivered-but-not-yet-processed events your system may have dropped (a crash between receiving and processing).Reprocessing / dead-letter — on a processing failure, call
markProcessed(state: FAIL, processingErrorMessage: ...), then run a retry job overfilterLogs(processedState: { eq: FAIL }).Auditing & SLA —
processedAtgives an authoritative "when did the integration finish" timestamp alongside Duro's delivery timestamps, without standing up your own tracking store.
Example: receive → process → report
Common Use Cases
ERP Integration
Sync component and BOM changes to your ERP system in real-time:
Slack Notifications
Alert your team about important component changes:
Audit Logging
Maintain a detailed audit trail of all changes:
Change Order Workflow Tracking
Monitor change order progress and sync approval status to external systems:
Troubleshooting
Common Issues
Webhook not receiving events
Check if webhook is enabled - Query the webhook and verify
isEnabled: trueVerify event subscriptions - Ensure the correct events are in the
eventsarrayCheck your endpoint - Verify your URL is accessible from the internet
Review logs - Use the
filterLogsquery to see delivery attempts and errors (filter onwebhookIdandisAcknowledged: { eq: false }to surface undelivered events)
Signature verification failing
Check the secret - Ensure you're using the exact same
signingSecretyou configured on the webhookUse the raw body - Signature is computed over the raw request bytes, not a re-serialized JSON object. Any middleware that parses or rewrites the body before verification will break the signature
Strip the
sha256=prefix - The header value issha256=<hex>, not a bare hex digest. Split on the first=and compare only the hex portionMatch digest lengths first -
crypto.timingSafeEqualthrows when buffers differ in length; guard with a length check before comparingCheck encoding - Ensure UTF-8 encoding throughout
Missing webhook deliveries
Check retry status - Some deliveries may be queued for retry
Verify library scope - Webhooks only fire for events in their configured library
Check component filters - Events fire for all components in the library
Testing Your Webhook Endpoint
Before configuring a production webhook, test your endpoint:
Migrating from v1 to v2
v1 webhook payloads included complete resource data. v2 uses a "Ping then Pull" pattern—you receive minimal metadata and fetch full details via GraphQL when needed.
Event Mapping
co.Submitted
CHANGE_ORDER_OPENED
co.Approved
CHANGE_ORDER_RESOLUTION
Check metadata.resolution === 'approved'
co.Rejected
CHANGE_ORDER_RESOLUTION
Check metadata.resolution === 'rejected'
Example: Change Order Approved
v1 payload (complete data embedded):
v2 payload (metadata only):
What's Not Included in v2
The following v1 fields are not in the v2 webhook payload. Use the GraphQL API to retrieve this data:
con, name, description
Query changeOrders.get()
status, type, resolution
Query changeOrders.get()
creator, firstName, lastName, email
Query changeOrders.get() with createdBy field
approverList
Query changeOrders.get() with reviewers field
children.components, children.products
Query changeOrders.get() with items field
history
Query change order audit log
created, lastModified
Query changeOrders.get() with createdAt, updatedAt
Updating Your Handler
v1:
v2:
See Fetch Change Order Details for the GraphQL query and Signing Secrets for signature verification.
Next Steps
Review Authentication for securing your API requests
Explore Searching and Filtering for advanced component queries
Learn about Change Orders to understand change order workflows
Check out Error Handling for robust integration patterns
Last updated
Was this helpful?