Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions obp-api/src/main/resources/props/sample.props.template
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,21 @@ long_endpoint_timeout = 55000
## always runs. A message that cannot be delivered is retried with backoff, on top of this interval.
#message_outbox.relay_interval_seconds=10

## Webhook requests sent through the message outbox (Dynamic Entity webhooks).
## Attempts after which a webhook request that keeps failing (5xx, 408, 429, timeout, no connection)
## stops being retried and is left STICKY. With the relay's backoff, 144 is roughly a day.
#webhook.outbox_max_attempts=144
## Allow webhook URLs with plain http. For local development only.
#webhook.allow_http=false
## Allow webhook URLs whose host is a loopback, private or link-local address, such as localhost or
## 10.0.0.5. For local development and tests only: in production this would let a webhook reach
## services inside OBP's own network.
#webhook.allow_private_addresses=false
## A webhook that has had this many requests queued within one minute is turned off with
## deactivated_reason CIRCUIT_BREAKER instead of firing again: the backstop against a receiver whose
## reply writes a record that fires the same webhook again.
#webhook.circuit_breaker_per_minute=60


## Enable user authentication via the connector
#connector.user.authentication=true
Expand Down
1 change: 1 addition & 0 deletions obp-api/src/main/scala/bootstrap/liftweb/Boot.scala
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,7 @@ object ToSchemify extends MdcLoggable {
code.platformapp.PlatformApp,
code.platformapp.PlatformAppRequiredScope,
code.domainapi.DomainApi,
code.dynamicentitywebhook.DynamicEntityWebhook,
PayeeLookup,
UtilityPaymentCallback,
BulkPayment,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,13 +208,18 @@ trait APIMethodsDynamicEndpoint {
dynamicData = DynamicDataProvider.connectorMethodProvider.vend.getAll(bankId, entityName, None, false)
dynamicJsonData = JArray(dynamicData.map(it => com.openbankproject.commons.util.JsonAliases.parse(it.dataJson)).map(_.asInstanceOf[JObject]))
entityObject = DynamicEndpointHelper.getObjectByKeyValuePair(dynamicJsonData, entityIdKey, entityIdValueFromUrl.get)
_ <- NewStyle.function.tryons(s"$InvalidEndpointMapping `response_mapping` must be linked to at least one valid dynamic entity!", 400, cc.callContext) {
val entityIdName = DynamicEntityHelper.createEntityId(entityName)
val entityIdValue = (entityObject \ entityIdName).asInstanceOf[JString].s
DynamicDataProvider.connectorMethodProvider.vend.delete(bankId, entityName, entityIdValue, None, false).head
entityIdName = DynamicEntityHelper.createEntityId(entityName)
entityIdValue <- NewStyle.function.tryons(s"$InvalidEndpointMapping `response_mapping` must be linked to at least one valid dynamic entity!", 400, cc.callContext) {
(entityObject \ entityIdName).asInstanceOf[JString].s
}
entityBody = JsonUtils.buildJson(json, requestMappingJvalue)
(box, _) <- NewStyle.function.invokeDynamicConnector(CREATE, entityName, Some(entityBody.asInstanceOf[JObject]), None, bankId, None, None, false, Some(cc))
// The record keeps its id: a PUT changes the record the URL names, and it is an update, so
// Dynamic Entity webhooks see UPDATED and can compare old and new values. The id field of the
// body is set to the record's id, so the stored values can never name a different record.
entityBody = JsonUtils.buildJson(json, requestMappingJvalue) match {
case body: JObject => JObject(body.obj.filterNot(_.name == entityIdName) :+ JField(entityIdName, JString(entityIdValue)))
case other => other
}
(box, _) <- NewStyle.function.invokeDynamicConnector(UPDATE, entityName, Some(entityBody.asInstanceOf[JObject]), Some(entityIdValue), bankId, None, None, false, Some(cc))
singleObject: JValue = unboxResult(box.asInstanceOf[Box[JValue]], entityName)
responseBodyScheme = DynamicEndpointHelper.prepareMappingFields(responseMappingJvalue)
responseBody = JsonUtils.buildJson(singleObject, responseBodyScheme)
Expand Down
14 changes: 14 additions & 0 deletions obp-api/src/main/scala/code/api/util/ApiRole.scala
Original file line number Diff line number Diff line change
Expand Up @@ -623,6 +623,20 @@ object ApiRole extends MdcLoggable{
case class CanDeleteDomainApi(requiresBankId: Boolean = true) extends ApiRole
lazy val canDeleteDomainApi = CanDeleteDomainApi()

// Dynamic Entity webhooks: requests OBP sends to a URL when records of a Dynamic Entity change. Held at a
// bank id, or at SYS for the system space, like the other Roles of the dynamic spaces.
case class CanCreateDynamicEntityWebhook(requiresBankId: Boolean = true) extends ApiRole
lazy val canCreateDynamicEntityWebhook = CanCreateDynamicEntityWebhook()

case class CanGetDynamicEntityWebhooks(requiresBankId: Boolean = true) extends ApiRole
lazy val canGetDynamicEntityWebhooks = CanGetDynamicEntityWebhooks()

case class CanUpdateDynamicEntityWebhook(requiresBankId: Boolean = true) extends ApiRole
lazy val canUpdateDynamicEntityWebhook = CanUpdateDynamicEntityWebhook()

case class CanDeleteDynamicEntityWebhook(requiresBankId: Boolean = true) extends ApiRole
lazy val canDeleteDynamicEntityWebhook = CanDeleteDynamicEntityWebhook()

// Shows which Consumers and client IP addresses are sending the most traffic to the instance
// (TrafficSources). About the instance, so held at the empty bank id. It names Consumers and IP
// addresses, which is why it is a Role of its own and not part of CanGetTelemetry.
Expand Down
9 changes: 9 additions & 0 deletions obp-api/src/main/scala/code/api/util/ErrorMessages.scala
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,15 @@ object ErrorMessages {
val DomainApiPathClash = "OBP-09031: Endpoints of this space are ambiguous with each other, so a Domain API cannot publish the space until one of them is renamed or removed: "
val DynamicPathAmbiguous = "OBP-09032: This would make a path ambiguous in its space. The Dynamic Entities and Dynamic Resource Docs of a space share one set of paths, which a Domain API can publish under its base path at any time, and a Domain API reads a hyphen in an entity name as an underscore, so a Dynamic Resource Doc's path may not start with a path variable, with my, public, community, openapi.json or openapi.yaml, or with the name of one of the space's Dynamic Entities in either spelling (like-this or like_this); a Dynamic Entity may not be named my, public, community, openapi.json or openapi.yaml, nor after the first segment of one of the space's Dynamic Resource Docs, nor so that it differs from another of the space's Dynamic Entities only by hyphens for underscores; and two Dynamic Resource Docs of one verb may not both match one request. Rename one of them: "
val DynamicEntityNameHasHyphenOrSpace = "OBP-09033: Invalid Dynamic Entity name. A Dynamic Entity name may not contain a hyphen or a space; use an underscore between words, e.g. 'customer_preferences'. A Domain API serves the entity at the hyphenated form of its name as well. "
// Dynamic Entity webhooks: requests OBP sends to a URL when records of a Dynamic Entity change.
val DynamicEntityWebhookNotFound = "OBP-09034: Dynamic Entity webhook not found for this Dynamic Entity. Please specify a valid value for WEBHOOK_ID."
val InvalidDynamicEntityWebhookEvents = "OBP-09035: Invalid events. events must name at least one of CREATED, UPDATED and DELETED, each at most once. "
val InvalidDynamicEntityWebhookFieldCondition = "OBP-09036: Invalid field_conditions. Field conditions apply to updates only, so events must include UPDATED; each condition must name a field of the Dynamic Entity, at most once, and its from and to values, where given, must fit that field's type and differ from each other. "
val InvalidDynamicEntityWebhookUrl = "OBP-09037: Invalid url. A webhook url must be an absolute https URL with a host name, at most 1024 characters, and must not point at this OBP instance or at a loopback, private, link-local or cloud metadata address. "
val DynamicEntityWebhookUrlAlreadyExists = "OBP-09038: This Dynamic Entity already has a webhook with this url. One webhook per url is enough, because a webhook fires when any of its field conditions matches; change that webhook instead. "
val InvalidDynamicEntityWebhookDescription = "OBP-09039: Invalid description. description must be at most 2000 characters."
val DynamicEntityWebhookDeliveryNotFound = "OBP-09040: Dynamic Entity webhook delivery not found for this webhook. Please specify a valid value for EVENT_ID."
val DynamicEntityWebhookDeliveryNotSticky = "OBP-09041: Only a STICKY delivery can be retried. A PENDING delivery is retried automatically, and a DELIVERED one is done. "


// General messages (OBP-10XXX)
Expand Down
46 changes: 46 additions & 0 deletions obp-api/src/main/scala/code/api/util/Glossary.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4365,6 +4365,52 @@ object Glossary extends MdcLoggable {
|
|**Resource Docs.** `/obp/v7.0.0/banks/BANK_ID/domain-apis/resource-docs?base_path=BASE_PATH` gives the same endpoints as the OpenAPI document as OBP Resource Docs, each at its full published path, for API Explorer and other OBP tools. No login is needed.
|
""".stripMargin)

glossaryItems += GlossaryItem(
title = "Dynamic Entity Webhooks",
description =
s"""
|# Dynamic Entity Webhooks
|
|A **Dynamic Entity webhook** makes OBP send a POST request to a URL when records of one Dynamic Entity (${getGlossaryItemLink("Dynamic-Entities")}) are created, updated or deleted, so that a system outside OBP learns of a change without polling for it.
|
|**What fires.** A record written through the endpoints: the Dynamic Entity endpoints, Domain APIs (${getGlossaryItemLink("Domain APIs")}), Dynamic Endpoints backed by a Dynamic Entity, and Dynamic Resource Docs whose code writes records. Changes to an entity's definition, backups, restores and rows written straight into the database fire nothing. Neither do records a connector stores in a bank's own system instead of OBP's database.
|
|**Identifiers only.** The request names the event, the bank, the entity and the record, never the record's values or any user id. A receiver that needs the record fetches it with its own credentials, so the normal access checks apply when it reads.
|
|**The request.** One POST per event, with these headers: `OBP-Webhook-Id`, `OBP-Event-Id`, `OBP-Delivery-Attempt` (1 on the first attempt), `OBP-Signature-Status` and, when requests are signed, `OBP-Signature`. The body:
|
|```json
|{
| "event_name": "dynamic_entity.record.updated",
| "event_id": "0f8e4a71-5ff1-4006-95bb-7fd9e4adaef9",
| "occurred_at": "2026-10-14T09:12:44Z",
| "webhook_id": "7d3c1c2e-5b7a-4f0e-9a51-2f1f6f0c9b11",
| "bank_id": "gh.29.uk",
| "entity_name": "loan_application",
| "record_id": "a1b2c3d4-0000-4000-8000-000000000001",
| "record_updated_at": "2026-10-14T09:12:44Z",
| "matched_conditions": [{"field": "approved", "from": false, "to": true}]
|}
|```
|
|`event_name` is `dynamic_entity.record.created`, `dynamic_entity.record.updated`, `dynamic_entity.record.deleted`, or `webhook.test` for a test request, which has no `record_id`. `bank_id` is `SYS` for an entity of the system space. `matched_conditions` is present only on an update sent because of field conditions, and lists the conditions that matched as the webhook's owner wrote them.
|
|**Receiving a request.** Reply 2xx as soon as the request is stored, and do the work afterwards: a slow reply is retried. Drop a request whose `event_id` was handled already, and one whose `record_updated_at` is older than the last handled for the same record, since requests can repeat and arrive out of order. To check a signature, read `kid` from the `OBP-Signature` header (a JSON Web Signature with a detached payload, RFC 7515 Appendix F), find the key with that `kid` at `GET /obp/v3.1.0/certs`, and verify the signature against the exact bytes of the body with any JOSE library. Then fetch the record, if it is needed, with the receiver's own credentials.
|
|**Events and field conditions.** A webhook subscribes to any of `CREATED`, `UPDATED` and `DELETED`. Its optional `field_conditions` narrow `UPDATED`: each names a field of the entity and, optionally, the value it must change `from` and the value it must change `to`. `{"field": "approved", "from": false, "to": true}` fires only on that change, `{"field": "status", "to": "CLOSED"}` on any change into `CLOSED`, and `{"field": "amount"}` on any change to `amount`. An update fires the webhook when at least one condition matches. The fields and values are checked against the entity's definition when the webhook is created or changed. A record created with `approved` already true is a `CREATED` event, not a match.
|
|**Delivery.** At least once, in no particular order. Each event carries an `event_id`, the same on every retry, to drop duplicates by, and the record's `record_updated_at`, to drop an event older than one already handled. Requests are queued in the same database transaction as the change, so a change that rolls back sends nothing, and are then sent by the message outbox. A 2xx reply counts as delivered; a 5xx, 408 or 429 reply, a timeout or a failed connection is retried with growing intervals; any other reply, a redirect included, is not retried. `POST .../web-hooks/WEBHOOK_ID/test` queues a `webhook.test` request, to check a receiver before relying on it. `GET .../web-hooks/WEBHOOK_ID/deliveries` lists a webhook's requests, newest first, each `PENDING`, `DELIVERED` or `STICKY` (no longer tried), with its attempts, the last reply's HTTP status and the last error; `POST .../deliveries/EVENT_ID/retry` sends a STICKY one again with the full number of attempts. Deleting a webhook drops its requests not yet delivered.
|
|**Signing.** When this instance has a stable server key (`jwt.use.ssl=true`), each request carries `OBP-Signature`, a JSON Web Signature over the exact request body made with the key published at `GET /obp/v3.1.0/certs`. Otherwise requests go out unsigned, with `OBP-Signature-Status: not_configured`. A webhook's `signature_status` says which; on this instance it is `${code.messageoutbox.WebhookOutbox.signatureStatus}`.
|
|**Where a webhook may send.** Only to an absolute `https` URL, never to this OBP instance, and never to a loopback, private, link-local or cloud metadata address, so that a webhook cannot be used to reach services inside OBP's own network. An entity has at most one webhook per URL.
|
|**Deactivation.** `is_active` false stops a webhook. `deactivated_reason` says why: `TURNED_OFF` when someone set it, `CIRCUIT_BREAKER` when it had ${code.dynamicentitywebhook.DynamicEntityWebhookFiring.circuitBreakerPerMinute} requests queued within a minute (on this instance), the backstop against a receiver whose reply fires the webhook again, `ENTITY_FIELD_REMOVED` or `ENTITY_FIELD_TYPE_CHANGED` when the entity's definition changed under one of its field conditions. Deleting a Dynamic Entity deletes its webhooks.
|
|**Managing.** `/obp/v7.0.0/management/banks/BANK_ID/dynamic-entities/DYNAMIC_ENTITY_ID/web-hooks` creates and lists an entity's webhooks, and `.../web-hooks/WEBHOOK_ID` reads, updates and deletes one. `/obp/v7.0.0/management/banks/BANK_ID/dynamic-entity-web-hooks` lists every webhook of a space. BANK_ID is a bank's id or `SYS` for the system space. The Roles are CanCreateDynamicEntityWebhook, CanGetDynamicEntityWebhooks, CanUpdateDynamicEntityWebhook and CanDeleteDynamicEntityWebhook, held at that BANK_ID.
|
""".stripMargin)

glossaryItems += GlossaryItem(
Expand Down
3 changes: 3 additions & 0 deletions obp-api/src/main/scala/code/api/v7_0_0/Http4s700.scala
Original file line number Diff line number Diff line change
Expand Up @@ -7775,6 +7775,9 @@ object Http4s700 {
// Domain APIs: a space's Dynamic Entities and Dynamic Resource Docs published under a base path of its own.
resourceDocs ++= Http4s700DomainApis.resourceDocs

// Dynamic Entity webhooks: URLs OBP sends a request to when records of a Dynamic Entity change.
resourceDocs ++= Http4s700DynamicEntityWebhooks.resourceDocs

// Groups: bring the members of a Group in line with its current Roles.
resourceDocs ++= Http4s700Groups.resourceDocs

Expand Down
Loading
Loading