Skip to content

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.

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

Set up an API endpoint that can receive HTTP POST requests from Tendium.

Webhooks can be created in the settings page if you have the necessary access. Only company admins can view and set up new webhooks.

  1. Go to https://app.tendium.com/settings/webhooks
  2. Click Create webhook
  3. Enter a Name
  4. Enter your Endpoint URL — this must be a valid, publicly available API that can receive HTTP POST requests
  5. Enter a Secret — any string that can be used to verify that the payload is coming from Tendium and has not been tampered with
  6. 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.

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.

  1. Go to https://app.tendium.com/settings/tokens
  2. Click Generate new token
  3. Store the token in a safe place — you will only be able to see it once

Tendium supports two bid events:

EventWhen it is sent
BidCreatedA bid is created — by adding a Tender or Call-Off to a bidflow, or by creating the bid manually.
BidUpdatedAn 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.

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.

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".

{
"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.

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.

Terminal window
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>"
}
}'

To test the update event instead, send the same request with "eventType": "BidUpdated". Nothing else in the body changes.

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.

  1. Take the string value from data.bid (it already contains your actual bidId).

  2. Send it to the URL provided in data.publicUrl under a query property:

    { "query": "<the query string from data.bid>" }

    Read the URL from data.publicUrl in the payload rather than hardcoding it.

  3. Provide the integration token in an Authorization header to authenticate the request:

    Authorization: Bearer <token>

    This is the only accepted form. The token may also be sent bare, without the Bearer prefix, but header names such as x-api-key, integration-token, x-integration-token and Tendium-Token are not accepted and will fail authentication.

  4. Include a User-Agent header in all requests to the Tendium API to avoid 503 Forbidden errors. Depending on the client you use, this may be provided automatically.

Replace <integrationToken> with your actual integration token, and <the query string from data.bid> with the value from the example payload.

Terminal window
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>"}'
{
"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" } }
]
}
}
}

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:

FieldNote
deadline, questionDeadline, publicationDate, lastUpdatedAtEpoch milliseconds, UTC. Not seconds, not ISO strings.
contractValue.amount, contractValue.currencyIndependently nullable — do not assume a currency accompanies an amount.
itemTypeFor bids that are not Procurement, the files and filesWithDates fields are not part of the query at all.
procurementStatusFree text taken from the source notice, not a fixed Tendium enum. Do not switch on the values.
lastUpdatedAtWhen the procurement data was last updated.
processStageThe bid’s stage name in Tendium. Configurable by your company, so it can be renamed.
cpvCodesRaw CPV code strings.
assignedToThe user id of the assignee, not an email address.
bidspaceThe bidflow the bid currently belongs to. Moving a bid between bidflows sends BidCreated, not BidUpdated.
files vs filesWithDatesfiles is always a list; filesWithDates may be null as a whole.

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:

FieldDescription
filesPublic procurement files
manualFilesFiles uploaded to the platform for the bid
filesWithDatesThe same procurement files, each with the timestamp it was added
manualFilesWithDatesThe 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.

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.

customFields: EditedCustomBidField[]
{
companyId: string
id: string
name: string | null
type: string
value: CustomBidFieldValue | null
}

The structure of value depends on the type. All properties inside value are nullable.

typevalue 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.

  1. Split the header by , — that is the separator for each key-value pair.
  2. Split each element from the previous result by = to get each key-value pair.
  3. You now have signature=<signature> and timestamp=<timestamp>.
  1. Extract the Tendium-Signature header and the raw request body from the incoming webhook request.
  2. Extract the signature and timestamp values from the header, as above.
  3. Concatenate the timestamp and the raw request body: <timestamp>.<requestBody>
  4. Compute the HMAC-SHA512 hex signature using the webhook’s secret and the value from step 3.
  5. 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-serialise
signed = f"{timestamp}.".encode() + raw_body
hmac.new(secret.encode(), signed, hashlib.sha512).hexdigest()

Use this to check your own signature verification implementation. The secret is TestSecret and the timestamp is 1752502290775.

Signature header value:

signature=50b8c0b4f20c2b1ca4e7ecb4150937493c4ecfb69d364e68b2ccd1d20c1eb9208db3299f4ee3ab2e6b942c1a6a5ba5c04aac1812225745da01e682200e2e4097,timestamp=1752502290775

Request 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

2f0c54c7a27b431216bb933e0a151feb0aa9199bc1ae15cee1e6a3b0001942d3f864034600e92bbbc1bb76f842f345d7a87951ea373260858ab50705a53321ab

for the same secret and timestamp.

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.

  • 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
ChangeBehaviour
Updates to the underlying procurement published by the buyer — a moved deadline, a changed status, newly published tender documentsNo 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 bidflowSends BidCreated, not BidUpdated.
Deleting a bidNo 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.

  • Delivery is at-least-once and best effort. A delivery can occasionally be missed.
  • BidUpdated is retried up to 3 attempts (roughly 1 second, then 2 seconds apart) if your endpoint does not respond successfully. BidCreated is 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-Signature header, 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.

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:

Arguments:

ParameterTypeRequiredDescription
signatureString!YesThe 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.
relatedEntityIdString!YesThe 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.
statusSendWebhookStatus!YesThe status of the webhook. Supported values: Unknown, Failed, Success.
messageStringNoA custom message the end user will see when hovering over the status icon in the platform, so make it user-friendly.

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.

Terminal window
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":{}}'
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:

FieldTypeDescription
changedAtDateTimeISO!The time at which the status was created or updated. A date-time string at UTC, such as 2007-12-03T10:15:30Z.
eventTypeWebhookEventType!The event type of the webhook, e.g. BidCreated.
messageStringAdditional information about the status of the webhook. Nullable.
statusSendWebhookStatus!The status of the webhook: Unknown, Failed or Success.

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:

PropertyDescription
errorsArray of all errors returned
errors[n].messageDetails about the error
errors[n].extensionsAdditional information about the error(s)