Accessing Docuseal Form Responses
When an employee submits a Docuseal-backed form task, we capture every field they filled in and store it on the task as submission_responses. This guide walks through subscribing to the webhook that tells you a submission happened, and reading the responses back out.
There are four steps:
- Get API credentials
- Create a webhook subscription
- Filter the events you care about
- Fetch and process the responses
1. Get API credentials
Email [email protected] to request API credentials. You will get back an API key and secret.
All API requests authenticate with HTTP Basic auth, using the key as the username and the secret as the password:
curl https://api.work-engine.com/api/v1/webhook_subscriptions \
-u "$API_KEY:$API_SECRET"Keep the secret somewhere safe — it is also the key used to sign webhook deliveries (see Verifying deliveries).
2. Create a webhook subscription
Subscribe to the user_document_created event, pointing at an HTTPS endpoint you control. Full webhook documentation, including the other available event types, lives here: https://talent-software.readme.io/docs/webhooks
Only private label admins can create webhook subscriptions. The credentials you were issued in step 1 must belong to a private label admin — any other role gets a
403from this endpoint. Ask support to confirm the role on your credentials if the request is rejected.A subscription covers every company under your private label. Subscriptions are scoped to the private label, not to a single company, so you will receive
user_document_createdevents for form submissions across all of its companies. Use thetarget_user_idon the event, or the task'scompany_id, to route each submission to the right place on your side.
Request
curl -X POST https://api.work-engine.com/api/v1/webhook_subscriptions \
-u "$API_KEY:$API_SECRET" \
-H 'Content-Type: application/json' \
-d '{
"event_type": "user_document_created",
"target_url": "https://example.com/hooks/onboarding"
}'| Field | Required | Notes |
|---|---|---|
event_type | yes | user_document_created for form submissions |
target_url | yes | Must be a valid HTTP(S) URL that returns a 2xx response |
Response
{
"id": "1042",
"event_type": "user_document_created",
"target_url": "https://example.com/hooks/onboarding",
"status": "enabled",
"created_at": "2026-09-03T14:22:31.000Z",
"updated_at": "2026-09-03T14:22:31.000Z"
}You can list, update, or delete subscriptions with the usual REST verbs on /api/v1/webhook_subscriptions and /api/v1/webhook_subscriptions/:id.
Keep your endpoint healthy. A delivery is retried up to 4 times with exponential backoff. If a subscription accumulates 4 failed events it is automatically set to
disabledand we email you. Re-enable it with aPATCHonce your endpoint is back up.
Verifying deliveries
Every delivery carries an X-Signature header: the HMAC-SHA256 hex digest of the raw request body, keyed with your API secret. Compare it against the raw body before you trust the payload.
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV['API_SECRET'], request.raw_post)
raise 'bad signature' unless ActiveSupport::SecurityUtils.secure_compare(expected, request.headers['X-Signature'])3. Filter the events you care about
user_document_created fires whenever any user document is created — uploads, signed documents, tax forms, and form tasks alike. You only want the form ones.
Event shape
The request body has two keys, and payload is a JSON-encoded string rather than a nested object — it arrives on a single line, escaped:
{
"event_type": "user_document_created",
"payload": "{\"id\":\"88213\",\"name\":\"Emergency Contact Form\",\"task_id\":\"55901\", ... }"
}Parse payload a second time to get the user document itself. Decoded and formatted, it looks like this:
{
"id": "88213",
"name": "Emergency Contact Form",
"task_id": "55901",
"task_name": "Emergency Contact Form",
"task_type": "form",
"target_user_id": "3310",
"target_user_name": "Dana Reyes",
"created_by_user_id": null,
"restricted": false,
"signature_required": true,
"file_url": "https://...",
"url": "/files/...",
"created_at": "2026-09-03T14:31:02.000Z",
"updated_at": "2026-09-03T14:31:02.000Z"
}Filter on task_type == "form", which is how a FormTask is serialized over the API. Then fetch the task (step 4) and skip it unless submission_responses is present — a form task with no Docuseal template attached, or one whose submission we could not read, will have null there.
# POST /hooks/onboarding
def create
event = JSON.parse(request.raw_post)
return head :ok unless event['event_type'] == 'user_document_created'
document = JSON.parse(event['payload'])
return head :ok unless document['task_type'] == 'form'
ImportFormResponsesJob.perform_later(document['task_id'])
head :ok
endRespond 2xx quickly and do the fetching on a background job — the delivery times out after 5 seconds.
4. Fetch and process the responses
Fetch the task by the task_id from the event. The responses live at task_data.submission_responses. The full task object and its task_data fields are documented in the API reference: https://talent-software.readme.io/reference/post_api-v1-companies-company-id-tasks
curl https://api.work-engine.com/api/v1/tasks/55901 \
-u "$API_KEY:$API_SECRET"{
"id": "55901",
"name": "Emergency Contact Form",
"task_type": "form",
"status": "complete",
"company_id": "412",
"target_user_id": "3310",
"target_user_name": "Dana Reyes",
"completed_at": "2026-09-03T14:31:02.000Z",
"task_data": {
"disclosure_statement": null,
"embed_src_url": "https://docuseal.com/s/abc123",
"restrict_document": false,
"signature_required": null,
"submission_identifier": "9f41c2",
"submission_responses": [
{
"field": "Contact name",
"uuid": "0f3c1d9a-7b21-4e0f-9d2a-1c4b8e5f6a70",
"value": "Jordan Reyes"
},
{
"field": "Relationship",
"uuid": "5b7e2c48-9a10-4f6d-8c33-2e9d7a1b4c55",
"value": "Spouse"
},
{
"field": "Phone",
"uuid": "c81a6f30-4d52-4b98-a7e1-6f0c3d9b2a44",
"value": "555-0142"
},
{
"field": "Signature",
"uuid": "e2d09b17-6c34-4a81-b5f9-8a7c1e0d3b62",
"value": "https://docuseal.com/blobs/..."
}
],
"user_document_id": "88213",
"docuseal_template_id": "77",
"questions": null
}
}Response shape
submission_responses is an array of objects, one per template field:
[
{
"field": "name of field",
"uuid": "unique identifier",
"value": "response for particular field"
}
]| Key | Description |
|---|---|
field | The field's name as it appears on the Docuseal template. Not unique — the same name can appear on multiple documents in one submission. |
uuid | The template field's stable unique identifier. Key your integration off this, not off field. |
value | The submitter's answer, always a string. Checkboxes come through as "true" / "false"; file and signature fields come through as a URL. |
Processing example
class ImportFormResponsesJob < ApplicationJob
FIELDS = {
'0f3c1d9a-7b21-4e0f-9d2a-1c4b8e5f6a70' => :emergency_contact_name,
'5b7e2c48-9a10-4f6d-8c33-2e9d7a1b4c55' => :emergency_contact_relationship,
'c81a6f30-4d52-4b98-a7e1-6f0c3d9b2a44' => :emergency_contact_phone,
}.freeze
def perform(task_id)
task = fetch_task(task_id)
responses = task.dig('task_data', 'submission_responses')
return if responses.blank?
attributes = responses.filter_map do |response|
key = FIELDS[response['uuid']]
[key, response['value']] if key
end.to_h
Employee.find_by!(external_id: task['target_user_id']).update!(attributes)
end
private
def fetch_task(task_id)
conn = Faraday.new(url: 'https://api.work-engine.com') do |f|
f.request :authorization, :basic, ENV.fetch('API_KEY'), ENV.fetch('API_SECRET')
f.response :json
f.response :raise_error
end
conn.get("/api/v1/tasks/#{task_id}").body
end
endimport os, requests
AUTH = (os.environ["API_KEY"], os.environ["API_SECRET"])
FIELDS = {
"0f3c1d9a-7b21-4e0f-9d2a-1c4b8e5f6a70": "emergency_contact_name",
"5b7e2c48-9a10-4f6d-8c33-2e9d7a1b4c55": "emergency_contact_relationship",
"c81a6f30-4d52-4b98-a7e1-6f0c3d9b2a44": "emergency_contact_phone",
}
def import_form_responses(task_id):
res = requests.get(
f"https://api.work-engine.com/api/v1/tasks/{task_id}", auth=AUTH, timeout=10
)
res.raise_for_status()
task = res.json()
responses = (task.get("task_data") or {}).get("submission_responses") or []
return {
FIELDS[r["uuid"]]: r["value"] for r in responses if r["uuid"] in FIELDS
}Troubleshooting
| Symptom | Likely cause |
|---|---|
403 creating the subscription | The credentials are not a private label admin's. Only private label admins may create webhook subscriptions — email support to have the role checked. |
| No events arrive at all | Subscription status is disabled after repeated delivery failures — GET /api/v1/webhook_subscriptions to check, then PATCH it back to enabled. |
Events arrive, but never with task_type: "form" | The onboarding tasks in question are a different task type. Only Docuseal form tasks serialize as "form". |
submission_responses is null | The task has no Docuseal template attached, or the submission had not been retrieved yet when you fetched. Retry the fetch shortly after. |
| Signature check fails | Sign the raw request body, before any JSON parsing or re-encoding. |
Questions, or a field shape that does not match what you see here? Email [email protected].
Updated 13 days ago

