Webhooks Integration Handbook
This handbook walks you through every step of integrating with Tendium using webhooks — from setting up your API endpoint to verifying payload signatures, handling bid updates, and reporting statuses back to Tendium.
Prerequisites
Section titled “Prerequisites”To set up webhooks in the Tendium platform, you will need:
- An endpoint that can receive HTTP POST requests
- Access to the Tendium platform to configure webhooks and generate tokens
- The ability to send HTTP POST requests to fetch additional information from Tendium
Step 0: Set up your API endpoint
Section titled “Step 0: Set up your API endpoint”Set up an API endpoint that can receive HTTP POST requests from Tendium.
Step 1: Configure webhooks in Tendium
Section titled “Step 1: Configure webhooks in Tendium”Create the webhook
Section titled “Create the webhook”Webhooks can be created in the settings page if you have the necessary access. Only company admins can view and set up new webhooks.
- Go to https://app.tendium.com/settings/webhooks
- Click Create webhook
- Enter a Name
- Enter your Endpoint URL — this must be a valid, publicly available API that can receive HTTP POST requests
- Enter a Secret — any string that can be used to verify that the payload is coming from Tendium and has not been tampered with
- Choose the Type of event: Bid created or Bid updated
Each webhook is registered for one event type. Tendium sends two bid events, BidCreated and BidUpdated (see Step 4). To receive both, create two webhooks — each has its own endpoint URL and its own secret.
The secret is per webhook, so if you register one webhook for each event type, each has its own secret. A payload can only be verified with the secret of the webhook that delivered it.
Create an integration token
Section titled “Create an integration token”To receive additional information (e.g., bid data) after receiving the initial webhook payload, you will need to make requests to Tendium’s public API using an integration token.
- Go to https://app.tendium.com/settings/tokens
- Click Generate new token
- Store the token in a safe place — you will only be able to see it once
Triggering the webhook
Section titled “Triggering the webhook”Tendium supports two bid events:
| Event | When it is sent |
|---|---|
BidCreated | A bid is created — by adding a Tender or Call-Off to a bidflow, or by creating the bid manually. |
BidUpdated | An existing bid changes in a way that is visible in the webhook data. See Step 4 for the full list of triggers. |
BidCreated can also be triggered manually at any time on an existing bid, using the send webhook button on the bid.
Automatic webhooks
Section titled “Automatic webhooks”In your bidflow, you can configure whether webhooks are sent automatically when you create bids.
This setting is stored per webhook, so if you have registered one webhook for each event type, the setting for BidCreated and the setting for BidUpdated are independent of each other. When no setting has been made for a bidflow, webhooks are sent automatically.
Step 1.1: Testing the webhook
Section titled “Step 1.1: Testing the webhook”Initiate the webhook by creating a bid. If the bidflow has automatic webhooks turned on, an HTTP POST request will be sent to the specified URL upon creating the bid.
Alternatively, you can manually trigger the webhook by pressing the send webhook button on the bid; the same bid can be sent again manually if needed.
To test BidUpdated, change an existing bid instead — there is no button for it. The body you receive is identical apart from "eventType": "BidUpdated".
Example payload
Section titled “Example payload”{ "eventType": "BidCreated", "data": { "bidId": "<bidId>", "itemId": "<itemId>", "bid": "query{webhookGetBid(input:\"<bidId>\"){id item{id name description specialData{buyerInformation{orgId orgName contactEmail}deadline contractDuration contractValue{amount currency}linkForSubmittingTender questionDeadline publicationDate isFrameworkAgreement cpvCodes processStage procurementStatus lastUpdatedAt}itemType}files filesWithDates{url addedAt}manualFiles manualFilesWithDates{url addedAt}assignedTo customFields{name type value{...on CustomBidFieldStringValue{string}...on CustomBidFieldArrayValue{unit array}...on CustomBidFieldNumberValue{number unit}...on CustomBidFieldUrlValue{title url}...on CustomBidFieldRangeValue{unit from to}...on CustomBidFieldDateValue{date}...on CustomBidFieldBooleanValue{boolean}...on CustomBidFieldMoneyValue{amount currency}...on CustomBidFieldDateRangeValue{from to}...on CustomBidFieldMoneyRangeValue{from to currency}}}bidspace{id name}}}", "publicUrl": "https://prod.public-gateway.radon.tendium.net/graphql", "bidDetailsPageUrl": "https://app.tendium.com/tender/<itemId>" }}The value of data.bid above is referred to throughout the rest of this page as the query string from data.bid. It is shown in full only here.
Step 1.2: Manual testing (optional)
Section titled “Step 1.2: Manual testing (optional)”You can test your integration without using the Tendium platform, by sending an HTTP POST request that mimics the payload structure above.
Replace <bidId> and <itemId> with real values if you want to properly test the full integration. To just test that your API can receive the payload and handle the properties, you can use the example values below or any string value.
curl -X POST <your-webhook-endpoint-url> \ -H "Content-Type: application/json" \ -d '{ "eventType": "BidCreated", "data": { "bidId": "<bidId>", "itemId": "<itemId>", "bid": "<the query string from data.bid>", "publicUrl": "https://prod.public-gateway.radon.tendium.net/graphql", "bidDetailsPageUrl": "https://app.tendium.com/tender/<itemId>" } }'curl -X POST http://webhooks.test/api \ -H "Content-Type: application/json" \ -d '{ "eventType": "BidCreated", "data": { "bidId": "3c5f9732-be86-468d-add9-d4fbc1a9cab8", "itemId": "67d1ad38374f070e35440bde", "bid": "<the query string from data.bid>", "publicUrl": "https://prod.public-gateway.radon.tendium.net/graphql", "bidDetailsPageUrl": "https://app.tendium.com/tender/67d1ad38374f070e35440bde" } }'To test the update event instead, send the same request with "eventType": "BidUpdated". Nothing else in the body changes.
Step 2: Fetching the bid data
Section titled “Step 2: Fetching the bid data”The initial webhook payload only contains a subset of relevant metadata fields related to the webhook event — not the full bid. You need one additional step to get the actual bid data.
The data.bid property contains a string; this string represents a GraphQL query that can be used to get the bid data. The string value can be used directly as an HTTP POST request, regardless of GraphQL familiarity.
This step is the same for BidCreated and BidUpdated — both events carry the same query, and for an update you simply run it again to get the bid’s current state.
-
Take the string value from
data.bid(it already contains your actual bidId). -
Send it to the URL provided in
data.publicUrlunder aqueryproperty:{ "query": "<the query string from data.bid>" }Read the URL from
data.publicUrlin the payload rather than hardcoding it. -
Provide the integration token in an
Authorizationheader to authenticate the request:Authorization: Bearer <token>This is the only accepted form. The token may also be sent bare, without the
Bearerprefix, but header names such asx-api-key,integration-token,x-integration-tokenandTendium-Tokenare not accepted and will fail authentication. -
Include a
User-Agentheader in all requests to the Tendium API to avoid503 Forbiddenerrors. Depending on the client you use, this may be provided automatically.
Full example using cURL
Section titled “Full example using cURL”Replace <integrationToken> with your actual integration token, and <the query string from data.bid> with the value from the example payload.
curl --location 'https://prod.public-gateway.radon.tendium.net/graphql' \ --header 'Authorization: Bearer <integrationToken>' \ --header 'Content-Type: application/json' \ --header 'User-Agent: some_agent_value' \ --data '{ "query": "<the query string from data.bid>"}'Example response
Section titled “Example response”{ "data": { "webhookGetBid": { "id": "<bidId>", "item": { "id": "string", "name": "string", "description": "string", "specialData": { "buyerInformation": { "orgId": "string", "orgName": "string", "contactEmail": "string" }, "deadline": "<number>", "contractDuration": "string", "contractValue": { "amount": "<number>", "currency": "string" }, "linkForSubmittingTender": "string", "questionDeadline": "<number>", "publicationDate": "<number>", "isFrameworkAgreement": "<boolean>", "cpvCodes": ["string"], "processStage": "string", "procurementStatus": ["string"], "lastUpdatedAt": "<number>" }, "itemType": "string" }, "files": [ "https://example-s3.com/signed-url-file1.pdf", "https://example-s3.com/signed-url-file2.pdf" ], "filesWithDates": [ { "url": "https://example-s3.com/signed-url-file1.pdf", "addedAt": "<number>" }, { "url": "https://example-s3.com/signed-url-file2.pdf", "addedAt": "<number>" } ], "manualFiles": [ "https://example-s3.com/signed-url-manual-file1.pdf", "https://example-s3.com/signed-url-manual-file2.pdf" ], "manualFilesWithDates": [ { "url": "https://example-s3.com/signed-url-manual-file1.pdf", "addedAt": "<number>" }, { "url": "https://example-s3.com/signed-url-manual-file2.pdf", "addedAt": "<number>" } ], "assignedTo": "string", "bidspace": { "id": "string", "name": "string" }, "customFields": [ { "name": "string", "type": "Date", "value": { "date": "<number>" } }, { "name": "string", "type": "Number", "value": { "number": "<number>", "unit": "string" } }, { "name": "string", "type": "String", "value": { "string": "string" } }, { "name": "string", "type": "DateRange", "value": { "from": "string", "to": "string" } }, { "name": "string", "type": "Money", "value": { "amount": "<number>", "currency": "string" } }, { "name": "string", "type": "URL", "value": { "title": "string", "url": "string" } } ] } }}Response schema
Section titled “Response schema”Because introspection is disabled, this section is the schema reference. Many fields are nullable — consumers must not assume a field is present.
type WebhookGetBidResponse { id: String! item: WebhookBidItem! files: [String!]! filesWithDates: [WebhookProcurementFile!] manualFiles: [String!]! manualFilesWithDates: [WebhookManualFile!]! assignedTo: String bidspace: WebhookBidspace customFields: [EditedCustomBidField!]!}
type WebhookBidItem { id: String! name: String description: String specialData: WebhookBidItemSpecialData! itemType: BidItemType!}
type WebhookBidItemSpecialData { buyerInformation: WebhookBidItemBuyerInformation! deadline: Float contractDuration: String contractValue: WebhookBidItemContractValue! linkForSubmittingTender: String questionDeadline: Float publicationDate: Float isFrameworkAgreement: Boolean cpvCodes: [String!] processStage: String procurementStatus: [String!] lastUpdatedAt: Float}
type WebhookBidItemBuyerInformation { orgId: String orgName: String contactEmail: String}
type WebhookBidItemContractValue { amount: Float currency: String}
type WebhookProcurementFile { url: String! addedAt: Float}
type WebhookManualFile { url: String! addedAt: Float}
type WebhookBidspace { id: String! name: String}
enum BidItemType { Procurement CallOff Manual}Notes that the types alone do not convey:
| Field | Note |
|---|---|
deadline, questionDeadline, publicationDate, lastUpdatedAt | Epoch milliseconds, UTC. Not seconds, not ISO strings. |
contractValue.amount, contractValue.currency | Independently nullable — do not assume a currency accompanies an amount. |
itemType | For bids that are not Procurement, the files and filesWithDates fields are not part of the query at all. |
procurementStatus | Free text taken from the source notice, not a fixed Tendium enum. Do not switch on the values. |
lastUpdatedAt | When the procurement data was last updated. |
processStage | The bid’s stage name in Tendium. Configurable by your company, so it can be renamed. |
cpvCodes | Raw CPV code strings. |
assignedTo | The user id of the assignee, not an email address. |
bidspace | The bidflow the bid currently belongs to. Moving a bid between bidflows sends BidCreated, not BidUpdated. |
files vs filesWithDates | files is always a list; filesWithDates may be null as a whole. |
Step 2.1: Files (optional)
Section titled “Step 2.1: Files (optional)”Before you start handling these, make sure that you have relevant access. To verify whether you have access, check that the initial webhook request payload includes the file properties in the query under data.bid.
If you have access to the files feature of webhooks, the bid payload will include these additional fields:
| Field | Description |
|---|---|
files | Public procurement files |
manualFiles | Files uploaded to the platform for the bid |
filesWithDates | The same procurement files, each with the timestamp it was added |
manualFilesWithDates | The same uploaded files, each with the timestamp it was added |
The filesWithDates and manualFilesWithDates variants contain the same URLs as files and manualFiles, with an addedAt timestamp (epoch milliseconds) per file. They are useful for detecting newly published documents without re-downloading everything.
To download the actual file, make an HTTP GET request to the URLs provided in files and manualFiles.
Step 2.2: Custom fields
Section titled “Step 2.2: Custom fields”Bids may contain custom fields defined by the customer. These fields are returned as an array under the customFields property in the bid payload.
Each custom field has a name, type, and a value object. The name and type values are set when the customer defines the custom fields in the settings page. The structure of value depends on the custom field’s type, and is set on the bid itself.
EditedCustomBidField
Section titled “EditedCustomBidField”customFields: EditedCustomBidField[]{ companyId: string id: string name: string | null type: string value: CustomBidFieldValue | null}CustomBidFieldValue
Section titled “CustomBidFieldValue”The structure of value depends on the type. All properties inside value are nullable.
type | value schema |
|---|---|
| String | { string: string | null } |
| Array | { array: string[] | null, unit: Unit | null } |
| Number | { number: number | null, unit: Unit | null } |
| Date | { date: number | null } |
| Boolean | { boolean: boolean | null } |
| DateRange | { from: string | null, to: string | null } |
| Range | { from: string | null, to: string | null, unit: Unit | null } |
| Money | { amount: number | null, currency: string | null } |
| MoneyRange | { from: string | null, to: string | null, currency: string | null } |
| URL | { title: string | null, url: string | null } |
Unit is one of Hours, Days, Months, Years, Percents, Meters, SquareMeters.
Step 3: Verifying payload signature (optional)
Section titled “Step 3: Verifying payload signature (optional)”For additional security, a signature header is included in the initial webhook payload. This signature can be verified to make sure that the payload has not been tampered with.
How you verify the signature depends on your own solution and what tool or programming language you are using. Below is a general example you can follow.
Algorithm: HMAC-SHA512
Header format:
Tendium-Signature: 'signature=<hmac-sha512>,timestamp=<timestamp>'The signature is computed using the secret value set in the creation of the webhook in the platform, together with the current timestamp and the request body:
hmac-sha512(secret, '<timestamp>.<requestBody>')Format: hex
Example header:
Tendium-Signature: "signature=ca41941f762d23726d60d38286e78c0e38b67c97161cbc3fb9c56937aee3482731f28343043291077791db4090f4c94a012e1a8be963c6a1f648238ecc727b1f,timestamp=1751558314610"Signature verification is identical for BidCreated and BidUpdated.
Extracting the header key-value pairs
Section titled “Extracting the header key-value pairs”- Split the header by
,— that is the separator for each key-value pair. - Split each element from the previous result by
=to get each key-value pair. - You now have
signature=<signature>andtimestamp=<timestamp>.
Verification steps
Section titled “Verification steps”- Extract the
Tendium-Signatureheader and the raw request body from the incoming webhook request. - Extract the
signatureandtimestampvalues from the header, as above. - Concatenate the timestamp and the raw request body:
<timestamp>.<requestBody> - Compute the HMAC-SHA512 hex signature using the webhook’s secret and the value from step 3.
- Compare the computed signature with the expected signature extracted from the header in step 2.
import hmac, hashlib
# raw_body is bytes, exactly as received - do not re-serialisesigned = f"{timestamp}.".encode() + raw_bodyhmac.new(secret.encode(), signed, hashlib.sha512).hexdigest()Test vector
Section titled “Test vector”Use this to check your own signature verification implementation. The secret is TestSecret and the timestamp is 1752502290775.
Signature header value:
signature=50b8c0b4f20c2b1ca4e7ecb4150937493c4ecfb69d364e68b2ccd1d20c1eb9208db3299f4ee3ab2e6b942c1a6a5ba5c04aac1812225745da01e682200e2e4097,timestamp=1752502290775Request body — exactly these bytes, on a single line:
{"eventType":"BidCreated","data":{"bidId":"be5f035e-0c8b-4cac-add5-945122b0c36f","itemId":"6866a4ceedd6623137b8bcb1","bid":"query{webhookGetBid(input:\"be5f035e-0c8b-4cac-add5-945122b0c36f\"){id item{id name description specialData{buyerInformation{orgId orgName contactEmail}deadline contractDuration contractValue{amount currency}linkForSubmittingTender questionDeadline publicationDate isFrameworkAgreement cpvCodes processStage procurementStatus}itemType}files filesWithDates{url addedAt}manualFiles manualFilesWithDates{url addedAt}assignedTo customFields{name type value{...on CustomBidFieldStringValue{string}...on CustomBidFieldArrayValue{unit array}...on CustomBidFieldNumberValue{number unit}...on CustomBidFieldUrlValue{title url}...on CustomBidFieldRangeValue{unit from to}...on CustomBidFieldDateValue{date}...on CustomBidFieldBooleanValue{boolean}...on CustomBidFieldMoneyValue{amount currency}...on CustomBidFieldDateRangeValue{from to}...on CustomBidFieldMoneyRangeValue{from to currency}}}bidspace{id name}}}","publicUrl":"https://prod.public-gateway.radon.tendium.net/graphql","bidDetailsPageUrl":"https://app.tendium.com/tender/6866a4ceedd6623137b8bcb1"}}Concatenating gives 1752502290775. followed immediately by that single-line body. Hashing it with the secret TestSecret must produce the signature above.
As a second self-test: the same body sent as a BidUpdated — that is, with "eventType":"BidUpdated" and nothing else changed — produces the signature
2f0c54c7a27b431216bb933e0a151feb0aa9199bc1ae15cee1e6a3b0001942d3f864034600e92bbbc1bb76f842f345d7a87951ea373260858ab50705a53321abfor the same secret and timestamp.
Step 4: BidUpdated
Section titled “Step 4: BidUpdated”BidUpdated is sent when a bid you have already received changes in a way that is visible in the webhook data.
It uses the same delivery model as BidCreated. The request body is identical apart from eventType, so Step 2 and Step 3 apply unchanged: run the query from data.bid to get the bid’s current state, and verify the signature the same way.
4.1 What triggers BidUpdated
Section titled “4.1 What triggers BidUpdated”- Editing bid fields in the platform, including edits propagated from a tender box
- Changing the bid’s stage
- Assigning or unassigning a user
- Updating item fields
- Changing a custom bid field value
- Attaching or removing a manual document on the bid
4.2 What does not trigger BidUpdated
Section titled “4.2 What does not trigger BidUpdated”| Change | Behaviour |
|---|---|
| Updates to the underlying procurement published by the buyer — a moved deadline, a changed status, newly published tender documents | No event is sent. These changes come from the source publication rather than from an edit in Tendium, and are not detected today. |
| Moving a bid to another bidflow | Sends BidCreated, not BidUpdated. |
| Deleting a bid | No event is sent; there is no delete event. |
Because of the first row, a webhook-only integration can drift out of date. If your integration must stay accurate, run a low-frequency reconciliation job that re-fetches bids you care about, in addition to consuming webhooks.
4.3 Delivery
Section titled “4.3 Delivery”- Delivery is at-least-once and best effort. A delivery can occasionally be missed.
BidUpdatedis retried up to 3 attempts (roughly 1 second, then 2 seconds apart) if your endpoint does not respond successfully.BidCreatedis not retried.- Each attempt times out after 30 seconds. Respond quickly — acknowledge with a 2xx and do your processing asynchronously.
- A delivery counts as successful when your endpoint returns a 2xx. The response body is ignored, and a redirect (3xx) counts as a failure.
- Updates are not batched, debounced or combined. Five quick edits to the same bid produce five separate deliveries.
- There is no ordering guarantee. Two changes made in quick succession can arrive out of order, which is another reason to re-fetch rather than apply the payload as a change.
- All attempts of a single delivery reuse the same
Tendium-Signatureheader, so you can use that header value to recognise a retry of something you have already processed.
Step 5: CRM or other integrations (optional)
Section titled “Step 5: CRM or other integrations (optional)”After receiving the webhook data from Tendium, you can send relevant data points to any CRM you are currently using. Tendium does not currently provide any out-of-the-box integrations, which means you will have to implement this in your solution.
Step 6: Webhook statuses (optional)
Section titled “Step 6: Webhook statuses (optional)”You can update the webhook status in the Tendium platform to give users information on whether the webhook payload has been successfully processed outside of Tendium.
The update webhook status request is a GraphQL mutation:
updateWebhookStatus mutation
Section titled “updateWebhookStatus mutation”Arguments:
| Parameter | Type | Required | Description |
|---|---|---|---|
signature | String! | Yes | The value provided in the Tendium-Signature header of the webhook request, e.g. 'signature=abc,timestamp=123'. Pass the header value verbatim, including the timestamp= part — this is what identifies the individual delivery. |
relatedEntityId | String! | Yes | The relational ID for which the webhook is created. For both BidCreated and BidUpdated this represents data.bidId, so it is not unique per delivery — signature is what distinguishes one delivery from another. |
status | SendWebhookStatus! | Yes | The status of the webhook. Supported values: Unknown, Failed, Success. |
message | String | No | A custom message the end user will see when hovering over the status icon in the platform, so make it user-friendly. |
cURL example
Section titled “cURL example”Replace <status> with one of Unknown, Failed or Success, <bidId> with the same bidId provided by Tendium in the initial webhook request, and <Tendium-Signature> with the header value from that request.
curl --request POST \ --header 'content-type: application/json' \ --header 'Authorization: Bearer <token>' \ --url https://prod.public-gateway.radon.tendium.net/graphql \ --data '{"query":"mutation updateWebhookStatus {\n updateWebhookStatus(input: { status: <status>, message: \"<message>\", relatedEntityId: \"<bidId>\", signature: \"<Tendium-Signature>\" }) {\n changedAt\n status\n }\n}","variables":{}}'GraphQL example
Section titled “GraphQL example”mutation updateWebhookStatus($input: UpdateWebhookStatusInput!) { updateWebhookStatus(input: $input) { changedAt eventType message status }}Variables:
{ "input": { "message": "<message>", "relatedEntityId": "<bidId>", "signature": "<signature>", "status": "<status>" }}Response:
{ "data": { "updateWebhookStatus": { "changedAt": "<DateTimeISO>", "eventType": "<EventType>", "message": "<message>", "status": "<status>" } }}Response fields:
| Field | Type | Description |
|---|---|---|
changedAt | DateTimeISO! | The time at which the status was created or updated. A date-time string at UTC, such as 2007-12-03T10:15:30Z. |
eventType | WebhookEventType! | The event type of the webhook, e.g. BidCreated. |
message | String | Additional information about the status of the webhook. Nullable. |
status | SendWebhookStatus! | The status of the webhook: Unknown, Failed or Success. |
Step 7: Error handling (optional)
Section titled “Step 7: Error handling (optional)”GraphQL APIs can return 200 OK response codes for errors, where REST typically produces 4xx and 5xx. These error codes can still occur and are often related to other unexpected issues like network problems.
Check for these properties in the response object for potential errors:
| Property | Description |
|---|---|
errors | Array of all errors returned |
errors[n].message | Details about the error |
errors[n].extensions | Additional information about the error(s) |