Affinda has implemented webhooks to allow the data extracted by Affinda to be 'pushed' to you when an event occurs (e.g. document parsed or document validated), instead of you having to constantly poll our API to 'pull' the data to your system.

📘

Webhooks can be created at an Organization or Workspace level

RESThooks

We have implemented a slightly extended version of webhook called “RESTful webhook”, or “resthook”. It allows you to programmatically manage webhook subscriptions like you would with any RESTful resources.

For end-users, it means setting up a webhook subscription is a matter of clicking buttons, and no copy-pasting of URLs and cryptic tokens.

Create a webhook subscription

There are 2 steps to creating a webhook:

  1. Subscribe to an event
  2. The receiver confirms its intention to subscribe

Subscribe to an event

Request

URL: Create a resthook subscriptions

Method: POST

Body:

{
  "targetUrl": "https://your-receiver-domain.com/receive/",
  "event": "resume.parse.completed",
  "organization": "{{organization_identifier}}"
}

Available events:

Event NameDescription
document.parse.completedDocument has completed parsing, the parsing may have succeeded or failed
document.parse.succeededDocument parsing has succeeded
document.parse.failedDocument parsing has failed
document.validate.completedDocument has been validated
document.classify.completedDocument has completed classifying, the classification may have succeeded or failed
document.classify.succeededDocument classification has succeeded
document.classify.failedDocument classification has failed
document.rejectedDocument has been rejected (automatically or by a user)

The following events are deprecated in API V3 and should be used only by customers using API V2. API v3 customers should use the 'document' events above.

Event NameDescription
resume.parse.completedResume has completed parsing, the parsing may have succeeded or failed
resume.parse.succeededResume parsing has succeeded
resume.parse.failedResume parsing has failed
invoice.parse.completedInvoice has completed parsing, the parsing may have succeeded or failed

Response

Status code: 201

Body:

{
  "id": 1,
  "event": "document.parse.completed",
  "targetUrl": "https://your-receiver-domain.com/receive/",
  "organization": {
    "identifier": "{{organization_identifier}}",
    "name": "{{organization_name}}",
    "userRole": "admin",
    "avatar": null,
    "resthookSignatureKey": "{{resthook_signature_key}}",
    "isTrial": false
  },
  "active": false,
  "version": "v3",
  "autoDeactivated": false,
  "autoDeactivateReason": ""
}

Receiver confirms intention to subscribe

After the last step, we’ll POST to the receiver at https://your-receiver-domain.com/receive/ with a X-Hook-Secret header. The receiver should respond to this request with a 200 status code, and then activate the subscription using the X-Hook-Secret as below

Request

URL: /v3/resthook_subscriptions/activate

Method: POST

Headers:

X-Hook-Secret: <The X-Hook-Secret you received>

Response

{
  "id": 1,
  "event": "document.parse.completed",
  "targetUrl": "https://your-receiver-domain.com/receive/",
  "organization": {
    "identifier": "{{organization_identifier}}",
    "name": "{{organization_name}}",
    "userRole": "admin",
    "avatar": null,
    "resthookSignatureKey": "{{resthook_signature_key}}",
    "isTrial": false
  },
  "active": true,
  "version": "v3",
  "autoDeactivated": false,
  "autoDeactivateReason": ""
}

Example code

import requests
from some_framework import make_response

def receiver(request):
  if "X-Hook-Secret" in request.headers:
    # Confirm subscibe intention (you can confirm now or save the "X-Hook-Secret" and confirm later)
    requests.post(
      'https://api.affinda.com/v3/resthook_subscriptions/activate',
      headers={
        'Authorization': 'Bearer <your-api-key>',
        'X-Hook-Secret': request.headers['X-Hook-Secret'],
      }
    )
    return make_response(status_code=200)
  else:
    # Handle webhook notification
    ...

You have confirmed your intention to subscribe and are now ready to start receiving webhook notifications!

Receive a webhook notification

Verify webhook payload integrity

Enable webhook payload signing

🚧

Enable webhook payload signing

It’s highly recommended to enable webhook payload signing, so that you can verify that the payload indeed comes from Affinda and has not been tampered with.

To enable webhook payload signing, setup a resthook signature key for your account

  • Go to Affinda
  • Click on the “Settings” tab of your organization page
  • In the section “Webhook Signature Key”, copy the generated key (or click “Regenerate” to get a new key)

Verify the webhook payload when you receive it

In the webhook notification that Affinda sends, there’s a header called X-Hook-Signature of the following format: <timestamp>.<signature>.

To verify the webhook payload, sign the request body with the Signature Key you set up in the previous step using SHA256, then compare the resulting signature with the signature found in the X-Hook-Signature header. Only process the data if the signatures match!

Confirm payload timestamp is within a short range of current time to prevent replay attacks.

Example code

import hashlib
import hmac
import json
import time
from some_framework import make_response

def receiver(request):
  if "X-Hook-Secret" in request.headers:
    # Confirm subscribe intention
    ...
  else:
    # Handle webhook notification
    sig_header = request.headers['X-Hook-Signature']
    timestamp, sig_received = sig_header.split('.')
    sig_key = b'your-signature-key'
    sig_calculated = hmac.new(sig_key, msg=request.body, digestmod=hashlib.sha256).hexdigest()
    
    # Verify signature
    sig_verified = hmac.compare_digest(sig_received, sig_calculated)
    
    # Verify timestamp to prevent replay attack
    body = json.loads(request.body)
    now = time.time()
    max_timestamp_diff = 10 * 60  # 10 minutes
    timestamp_verified = (
      body["timestamp"] == int(timestamp) and 
      now - body["timestamp"] < max_timestamp_diff
    )
    
    if sig_verified and timestamp_verified:
      print('Data is safe to process')
      process_data(body)
    else:
      print("Spies detected! Don't process the data")
      
    return make_response(status_code=200)

Respond to the webhook notification

If we receive status code 200, the webhook notification is considered successfully received.

If we receive status code 410, the receiver is considered “gone” and we'll automatically delete your webhook subscription.

If we receive any other 4xx, 5xx status code, we’ll retry sending the webhook notification in increasingly wider intervals (exponential backoff retry strategy), and eventually stop retrying after about 1 day.

Webhook payload reference

The webhook payload is the metadata of the document. You can use it to retrieve the full parsed data if you need to.

{
  "id": "e3bd1942-635b-4971-b8f1-59543b0b2f1f",
  "event": "document.parse.completed",
  "timestamp": 1665637107,
  "payload": {
      "identifier": "abcdXYZ",
      "ready": true,
      "failed": false,
      "readyDt": "2023-02-10T08:05:30.775110Z",
      "fileName": "resume.pdf",
      "expiryTime": null,
      "language": "en",
      "pdf": "{{url_to_pdf}}",
      "parentDocument": null,
      "childDocuments": [],
      "pages": [
          {
              "id": 123,
              "pageIndex": 0,
              "image": null,
              "height": 841.0,
              "width": 595.0,
              "rotation": 0
          }
      ],
      "ocrConfidence": null,
      "reviewUrl": null,
      "createdDt": "2023-02-10T08:05:28.871650Z",
      "errorDetail": null,
      "file": "{{url_to_file}}",
      "collection": {
          "identifier": "abcdXYZ",
          "name": "Resume Parses",
          "extractor": {
              "id": 6,
              "identifier": "resume",
              "name": "Resume Parse",
              "baseExtractor": null,
              "validatable": false
          }
      },
      "workspace": {
          "identifier": "abcdXYZ",
          "name": "Recruitment"
      },
      "tags": [],
      "isOcrd": false,
      "isConfirmed": true,
      "confirmedDt": "2023-02-10T08:05:30.775110Z",
      "isRejected": false,
      "rejectedDt": null,
      "isArchived": false,
      "archivedDt": null,
      "errorCode": null
  }
}

Update webhook subscription

Request

URL: Update a resthook subscription

Method: PATCH

Body:

{
  "event": "document.parse.failed"
}

Response

Status code: 200

Body:

{
    "id": 1,
    "event": "document.parse.failed",
    "targetUrl": "https://your-receiver-domain.com/receive/",
    "organization": {
        "identifier": "abcdXYZ",
        "name": "Affinda",
        "userRole": "admin",
        "avatar": null,
        "resthookSignatureKey": "KEY",
        "isTrial": false
    },
    "active": true,
    "version": "v3",
    "autoDeactivated": false,
    "autoDeactivateReason": ""
}

Delete webhook subscription

Request

URL: Delete a resthook subscription

Method: DELETE

Response

Status code: 204

List webhook subscriptions

Request

URL: Get list of all resthook subscriptions

Method: GET

Response

Status code: 200

Body:

{
  "count": 1,
  "next": null,
  "previous": null,
  "results": [
    {
      "id": 1,
      "event": "document.parse.completed",
      "targetUrl": "https://your-receiver-domain.com",
      "organization": {
        "identifier": "abcdXYZ",
        "name": "Affinda",
        "userRole": "admin",
        "avatar": null,
        "resthookSignatureKey": "KEY",
        "isTrial": false
      },
      "active": true,
      "version": "v3",
      "autoDeactivated": false,
      "autoDeactivateReason": ""
    },
  ]
}

Retrieve webhook subscription

Request

URL: Get specific resthook subscription

Method: GET

Response

Status code: 200

Body:

{
  "id": 1,
  "event": "document.parse.completed",
  "targetUrl": "https://your-receiver-domain.com/receive/",
  "organization": {
    "identifier": "abcdXYZ",
    "name": "Affinda",
    "userRole": "admin",
    "avatar": null,
    "resthookSignatureKey": "KEY",
    "isTrial": false
  },
  "active": true,
  "version": "v3",
  "autoDeactivated": false,
  "autoDeactivateReason": ""
}