Skip to main content

Migrating from PSAP Connector v2 to NG-SOS Webhooks v3

This guide is for integrations that implement PSAP Connector v2 and are moving to NG-SOS Webhooks v3. v2 remains available for existing integrations; v3 is a new contract with source-aware caller data, hierarchical routes, and capability negotiation.

Plan the migration as a new webhook implementation. Do not extend the v2 DTOs field by field: the v3 caller and location models are different.

What stays the same

  • The transport is HTTPS + JSON, with NG-SOS acting as the client. Response bodies are ignored.
  • GET /about and GET /health remain at the shared paths.
  • NG-SOS sends one major webhook version to an integration at a time. It does not mix v2 and v3 event routes for one connector worker.
  • Capability negotiation remains incremental. NG-SOS sends a specific event only when the corresponding capability is reported. If the specific capability is missing and supports.incident.changed is true, NG-SOS sends the generic changed event instead.
  • Every v3 event answers with exactly 200, the create operation included. Any other status — 201 and 204 among them — counts as a failed delivery and is retried. Deliveries for one incident are ordered and run one at a time, so a rejected delivery holds back the events behind it. /about and /health are not deliveries and answer with ordinary HTTP semantics.

Every v3 event requires these headers. They do not belong on /about or /health:

HeaderMeaning
NG-SOS-Request-IdIdentifier of the delivery. A retry keeps the same value. Deduplicate by this value.
NG-SOS-Request-TimestampUTC timestamp associated with the delivery.

Version discovery and cutover

There is no separate v2/v3 version setting in the HTTP connector configuration. NG-SOS discovers the version from the version value returned by GET /about and selects the corresponding major version.

During discovery NG-SOS first calls /about with the legacy PsapConnector credential. If that request returns 401 or 403, NG-SOS retries the same path with the v3 Publisher credential. A v3-only integration should therefore keep /about available and return 401 or 403 for an invalid legacy credential so the Publisher retry can select v3.

Deploy the v3 routes alongside the v2 routes, then return a v3 version from /about:

{
"version": "3.0.0",
"supports": {
"incident": {
"created": true,
"changed": true
}
},
"allowsMonitoring": false
}

supports is required, and NG-SOS acts on it. Every nested group and flag is optional; a missing group or flag means that operation is unsupported, and an unsupported operation is either replaced by the generic incident-changed notification or not delivered at all. Declare only what the receiver has implemented.

allowsMonitoring is not like that. It is optional, defaults to false, and is advisory: NG-SOS records the answer and does not act on it, so a receiver that reports false still receives test incidents. It is a declaration, not a filter — see Incident purpose. Test incidents must not be presented as real emergencies. Demo incidents are operational incidents and should be handled normally.

Once v3 is selected and running in production, the v2 event routes can be removed. Keep the shared /about and /health endpoints available for discovery and health checks.

Token validation

Run v2 and v3 validation side by side during the transition. The v3 token identity is different:

Claim or scopev2v3
audurn:ng-sos:agency:{your-agency-id}urn:ng-sos:webhooks:{your-agency-id}
sub and client_idPsapConnectorPublisher
scopeThe scope required by the called v2 operationwebhooks.receive on every v3 route

The agency ID in the v3 audience is the lowercase UUID assigned during onboarding. Validate the token signature, issuer, lifetime, token type, audience, client identity, and webhooks.receive scope. That single scope authorizes Publisher to call this Webhooks integration; it does not control which event is delivered. NG-SOS sends webhook events only to HTTPS endpoints with a valid server certificate.

Validate against these values:

WhatValue
Issuer (iss)https://identity.ng-sos.com
Discovery documenthttps://identity.ng-sos.com/.well-known/openid-configuration
Signing keysThe jwks_uri published by that discovery document — fetch and cache it, never pin a key
Token type (typ)at+jwt
Audience (aud)urn:ng-sos:webhooks:{your-agency-id}, the agency ID as a lowercase dashed UUID
sub and client_idPublisher
scopeContains webhooks.receive

Any OpenID Connect middleware resolves the keys from the discovery document on its own; point it at the issuer as its authority and let it handle key rollover. Allow outbound access to identity.ng-sos.com from the receiver so key retrieval succeeds — see the network access whitelist.

Renames

v2v3Meaning
Psap, PsapIdAgency, AgencyIdThe outbound API uses the broader agency terminology.
psapsinvolvedAgenciesEach involved agency also carries its collaboration state and, when it was invited, the inviting agency.
Position, positionSourceLocation, sourceLocation is delivered in source-specific tracks.
captureAtUtccapturedAtUtcCorrected timestamp name.
createdTimeUtccreatedAtUtcCorrected timestamp name.
verticalAccuracyaltitudeAccuracyAccuracy of the altitude value.
requestId, requestTimeStampUtc in the bodyNG-SOS-Request-Id, NG-SOS-Request-Timestamp in headersDelivery metadata moved to HTTP headers.
isTestpurposeReal, Test, or Demo, set by NG-SOS. isTest: true corresponds to Test.
classification on the incidentcaller.reportedProblemsThe same thirteen flags, moved onto the caller. deadlock is now trapped.
caller.fullnamecaller.fullNameCorrected casing.
caller.phoneNumbers (array)caller.phoneNumber (nullable single value)v3 delivers the one current caller number, not a list. Both members are required, but where v2 sent [] for no resolved number, v3 sends null — model it as nullable or valid deliveries are rejected.
caller.gendercaller.medicData.person.sexSame Sex values, moved into the grouped medical data.
caller.height, caller.weightcaller.medicData.person.height, .weightSame units (centimetres, kilograms).
deviceInfo on the incidentcaller.deviceInfo (nullable)Moved onto the caller. Both members are required, but where v2 sent a default-valued object for an unknown device, v3 sends null.
psap.nameagency.displayName
permanentResidence.geoPointpermanentResidence.coordinates

Medical data moved from flat flags into groups

Every v2 caller.medicData member changes shape, not just its name. A mapper that copies the flat flags across silently loses medical conditions.

v2 caller.medicDatav3 caller.medicData
hearingImpairmentassistance.hearing.reported
blindassistance.vision.reported
speechDefectassistance.speech.reported
diabetesconditions.diabetes.reported
epilepsyconditions.epilepsy.reported
heartconditions.heart.reported
lungsconditions.lungs.reported
mentalDisordersconditions.psychiatric.reported
regularPrescriptionmedications
otherNo single counterpart — see below
lastUpdatedUtcNo counterpart

Each flag is an object carrying reported and nothing else. The free text v2 had nowhere to put sits one level up, on the group rather than on the flag: one conditions.description for all five conditions, and one assistance.description for all three assistance needs. Do not model a description beside each reported; there is none. Two members do pair a status with their own description, and neither is one of these flags: conditions.pregnancy has reported and description, and organDonation has status and description.

The v2 other field was never one value: NG-SOS composed it by joining the labelled parts Allergies, Advance directives, Conditions, Pregnancy, Assistance, Organ donation and the caller note, one per line. In v3 each is its own member — allergies, advanceDirectives, conditions.description, conditions.pregnancy, assistance.description, organDonation and notes.caller. Read them individually rather than parsing the joined text.

caller.medicData itself is nullable in both versions, but where v2 could omit the member entirely, v3 always sends the key — with null when nothing is known.

Renamed enum values

Value renames are easy to miss because the property name does not change. A mapper that carries v2 values across unchanged fails on all of these.

Enumv2v3
Position/location sourceManualEntered
Internet connection typeG2, G3, G4, G5Cellular2G, Cellular3G, Cellular4G, Cellular5G

v3 also adds the location source MobileAppSms. v2 delivered these locations too, folded into MobileApp; only the distinct value is new, so they are not a new class of location after cutover. The v1 and v2 position-source values were corrected in the same release, so a receiver holding an older copy of those contracts should re-read them; see Compatibility notes for v1 and v2.

Not a rename: age became yearOfBirth

v2v3
caller.age — the caller's age in yearscaller.medicData.person.yearOfBirth — the year the caller was born

Both are integers, so a field-to-field mapper compiles, passes schema validation and then puts an age into a year-of-birth field. Take yearOfBirth straight from v3 rather than deriving an age from it. If you convert persisted v2 payloads instead, anchor the conversion to the year the delivery was made, not to the year you run the conversion: NG-SOS produced the v2 value as deliveryYear - yearOfBirth, so yearOfBirth = deliveryYear - age and any other year yields a birth year that is off by one or more.

Removed

These v2 members have no v3 counterpart: deviceInfo.deviceId, deviceInfo.deviceName, deviceInfo.manufacturer, appInfo.type and caller.medicData.lastUpdatedUtc. v3 publishes no freshness timestamp for medical data anywhere, so a receiver that tracked staleness from lastUpdatedUtc needs another basis for it.

caller.temporaryInformation is gone as a member but its content is not dropped: while the warning is unexpired, NG-SOS merges temporaryInformation.message into caller.medicData.notes.caller alongside the caller's own note. Only validUntilUtc has no counterpart, so a receiver that displayed the warning separately should keep presenting the caller notes rather than treating the member as removed.

Route mapping

The base route changes from /v2 to /v3. Caller-related operations use hierarchical routes.

v2v3
POST /v2/incidentPOST /v3/incidents
POST /v2/incident/{incidentId} (close)POST /v3/incidents/{incidentId}/closed
POST /v2/incident/{incidentId}/positionPOST /v3/incidents/{incidentId}/caller/track
PUT /v2/incident/{incidentId}/position (replace)PUT /v3/incidents/{incidentId}/caller/track
POST /v2/incident/{incidentId}/psapPOST /v3/incidents/{incidentId}/involved-agency
PATCH /v2/incident/{incidentId}/battery-levelPATCH /v3/incidents/{incidentId}/caller/device-info
PATCH /v2/incident/{incidentId}/internet-connection-typePATCH /v3/incidents/{incidentId}/caller/device-info
POST /v2/incident/{incidentId}/start-videoPOST /v3/incidents/{incidentId}/caller/live-video/activated

V3 also adds these operations:

MethodRoute
POST/v3/incidents/{incidentId}/changed
POST/v3/incidents/{incidentId}/caller/chat/activated
POST/v3/incidents/{incidentId}/caller/live-video/ended
PUT/v3/incidents/{incidentId}/caller
PATCH/v3/incidents/{incidentId}/caller/full-name
PATCH/v3/incidents/{incidentId}/caller/phone-number
PATCH/v3/incidents/{incidentId}/call-sign
PATCH/v3/incidents/{incidentId}/label
PATCH/v3/incidents/{incidentId}/patient/description
PATCH/v3/incidents/{incidentId}/summary
PATCH/v3/incidents/{incidentId}/involved-agency/{agencyId}/collaboration

Location tracks

The incident snapshot carries the caller position in caller.tracks: one entry per location source that has produced a location, each holding the whole current curated track for that source. There is no single caller.location field, because v3 keeps every source separate.

Two operations carry a later location change, on the same path as the two v2 position operations had.

POST /caller/track carries one new location, and is how a caller position normally arrives:

{
"location": {
"type": "Measured",
"accuracy": 8.0,
"altitude": 238,
"altitudeAccuracy": 8,
"latitude": 49.1953,
"longitude": 16.6071,
"capturedAtUtc": "2026-01-01T00:01:30Z",
"receivedAtUtc": "2026-01-01T00:01:31Z",
"source": "MobileApp"
}
}

The location names its own source, so the body carries nothing else. Append it to the end of the track you hold for that source and leave the rest of that track alone; never read it as a whole track. It arrives only for a track you already received in full.

PUT /caller/track replaces one source's whole track, in the same shape as one caller.tracks entry:

{
"source": "MobileApp",
"track": [
{
"type": "Measured",
"accuracy": 12.5,
"altitude": 237,
"altitudeAccuracy": 8,
"latitude": 49.1951,
"longitude": 16.6068,
"capturedAtUtc": "2026-01-01T00:01:00Z",
"receivedAtUtc": "2026-01-01T00:01:01Z",
"source": "MobileApp"
}
]
}

It carries the changes a new location cannot express. The usual one is precision: a caller first located by cell tower, to within hundreds of metres, is then located by GPS to within a few. The coarse fix is not a place the caller has been, so it is discarded rather than kept as track history, and the track delivered here is the truth from then on. PUT /caller/track therefore remains the operation that defines the track, exactly as PUT /v2/incident/{incidentId}/position did.

Report supports.incident.caller.newLocation only together with supports.incident.caller.trackReplaced. Reporting the replacement alone still delivers every change, as a whole track, which is what v2 did. An integration that reports neither, and does not implement supports.incident.created either, never receives a caller position. A track event is sent for each changed non-Apple source and never carries an empty track. Apple EED tracks are never sent, and Apple EED never appears in caller.tracks.

Location kinds

A location is one of two variants, named by its type, and they do not carry the same members. Measured is a location a device measured. It always carries accuracy, and its altitude and altitudeAccuracy are present but may be null when the device did not report them. Known is a location that was entered rather than measured — a dispatcher moving the caller pin, or a partner system creating the incident — and carries latitude, longitude, capturedAtUtc, receivedAtUtc and source, and nothing else. It has no accuracy, no altitude and no altitudeAccuracy, because none of them exist for a location nobody measured. Give the two variants separate types rather than one type with nullable altitude and accuracy. Read type before reading any of those three members. Redacted locations never appear in v3.

When an incident is created with existing tracks, NG-SOS sends the create event first and then one whole-track replacement per non-empty non-Apple source. Those track events repeat what the create event already delivered in caller.tracks; because the operation replaces the whole track for its source, applying them again changes nothing. An integration that implements incident.created can read that first round as confirmation rather than change. They are sent unconditionally so that an integration that does not implement incident.created still receives the position. When a new agency becomes involved, its integration receives the same create-and-track bootstrap; agencies already involved receive the agency-invited event. A newly involved agency is never sent an appended location for a track it has not yet received in full.

Incident and caller payloads

The create request body is { "incident": { ... } }. The incident contains the complete snapshot available to the integration:

  • id;
  • purpose, with values Real, Test, or Demo, described in Incident purpose;
  • openUrl and createdAtUtc;
  • caller;
  • involvedAgencies;
  • nullable label, summary, and callSign;
  • patient.description.

Use every delivered openUrl value. Do not construct Portal URLs. The URLs in the incident, chat, and live-video objects may point to the same Portal incident page.

caller contains the resolved caller state, including the current location tracks:

dataSources, deviceInfo, fullName, phoneNumber, email,
insuranceNumber, languages, permanentResidence, contacts,
reportedProblems, medicData, chat, liveVideo, tracks

dataSources reports which kinds of source contributed to the caller, not an instance history: each member is present only when that kind was used, and null otherwise. A caller who never used a source keeps that member null.

SourceFields when present
mobileAppappInfo
webApp(none — its device information is already part of the resolved deviceInfo)
appleEedhasLocation, hasLiveVideo, hasMediaUpload, hasAdditionalData
googleEls(none)

NG-SOS no longer reports emergency-SMS or AML linkage through dataSources; a caller who arrived through either still appears in caller.tracks under the MobileAppSms/Aml location source when a track exists.

Apple EED coordinates and other Apple emergency data do not leave NG-SOS; the dispatcher sees them in Portal. The appleEed member reports availability and capabilities only: hasLocation states that NG-SOS holds an Apple position, not that one will be delivered, and hasLiveVideo/hasMediaUpload/hasAdditionalData state that Apple offered those options for the session, not that NG-SOS delivers anything behind them over this integration. Google ELS data exposed by v3 is source-specific locations only, not the complete upstream delivery; the category the caller selected when placing the call is not delivered over this integration.

When a caller linked more than one source of the same kind over the incident's life, the member reflects the most recently linked one.

The resolved deviceInfo contains batteryLevel, operating system, operating-system version, internetConnectionType, liveVideoSupport, and automatic detections.

The v3 medicData structure is grouped into person, conditions, assistance, and notes. It also carries optional medications, allergies, organ-donation information, and advance directives. The old flat v2 medical object must not be reused as the v3 DTO.

Incident purpose

purpose replaces the v2 isTest flag. NG-SOS sets it from the way the incident was created; neither an integrator nor a partner system can choose it.

ValueWhen NG-SOS sends itHow to handle it
RealEvery incident raised by a caller through the mobile app, web app, SMS, AML, Apple EED or Google ELS, and every incident a partner system creates through the NG-SOS API.Dispatch normally.
TestAn incident NG-SOS creates from its connector administration screen to exercise one agency's integration.Accept and acknowledge the delivery, but never present it on the dispatch floor as a real emergency.
DemoThe seeded demonstration incidents used for training and product demonstrations.Handle like a real incident.

Test is how an integration is verified without faking an emergency: ask NG-SOS to create a test incident for your agency. It is delivered over the same routes, with the same headers and the same token as any other incident, and it is the only value a receiver must treat differently.

allowsMonitoring in GET /about does not gate this. NG-SOS reads the flag and does not act on it, so every receiver — including one that reports allowsMonitoring: false — must be able to accept a Test incident and keep it off the dispatch floor. The flag records an intention, not a guarantee; that may change in a future release, so report it truthfully.

The same incident reaches a v1 or v2 integration with isTest: true. Real and Demo both reach it with isTest: false.

Capability flags

The supports tree mirrors the v3 event paths:

{
"incident": {
"created": true,
"closed": true,
"changed": true,
"caller": {
"changed": true,
"newLocation": true,
"trackReplaced": true,
"chat": { "activated": true },
"liveVideo": { "activated": true, "ended": true }
},
"involvedAgency": {
"invited": true,
"collaboration": { "changed": true }
},
"labelChanged": true,
"summaryChanged": true,
"callSignChanged": true,
"patient": { "descriptionChanged": true }
}
}

Declare at least one of incident.created and incident.changed. An integration that declares neither can never be told that an incident exists; NG-SOS rejects such an /about response and does not start the integration. Every other flag and group is optional and means unsupported when it is missing.

Choose either the composed caller or the three specific caller fields

caller.changed is not additive with caller.fullNameChanged, caller.phoneNumberChanged and caller.deviceInfoChanged. It supersedes them. Reporting caller.changed means the caller's full name, phone number and device information reach you inside PUT /caller — and the three PATCH routes for them are then never called at all, whether or not you also report their flags. This is not limited to a new source attaching; it holds for every change to those three values.

Pick one of the two shapes:

You reportYou receive for name, phone number and device informationImplement
caller.changed: truePUT /caller with the whole composed callerPUT /v3/incidents/{incidentId}/caller only
caller.changed absent or false, plus any of the three specific flagsThe matching PATCH route per changed valueEach PATCH route whose flag you report

The example above takes the first shape, which is why it reports no specific caller-field flags. Report the second shape instead when you would rather patch individual fields than re-read the whole caller:

{
"incident": {
"created": true,
"caller": {
"fullNameChanged": true,
"phoneNumberChanged": true,
"deviceInfoChanged": true,
"trackReplaced": true
}
}
}

Under the second shape, caller changes that none of the three specific routes can express — a new data source, changed medical data, a changed permanent residence — have no specific route either. They arrive as the generic POST /changed when you report incident.changed, and are not delivered at all when you do not.

Location tracks are outside this choice. caller.newLocation and caller.trackReplaced are delivered under both shapes, so a receiver that reports caller.changed still gets track events in addition to the tracks already inside PUT /caller.

The main event mapping is:

Snapshot changeV3 operation
Incident createdPOST /v3/incidents
Caller source or composed caller data changedPUT /caller, or the generic POST /changed when caller.changed is not reported
A new location for one sourcePOST /caller/track
One source track was replacedPUT /caller/track
Resolved device information changedPATCH /caller/device-info, or PUT /caller when caller.changed is reported
Caller name changedPATCH /caller/full-name, or PUT /caller when caller.changed is reported
A caller phone number became availablePATCH /caller/phone-number, or PUT /caller when caller.changed is reported
Agency invited to collaboratePOST /involved-agency
Collaboration state changedPATCH /involved-agency/{agencyId}/collaboration
Label, summary, call sign, or patient description changedThe corresponding PATCH operation
Chat activatedPOST /caller/chat/activated
Live video activated or endedThe corresponding POST operation
Incident closedPOST /closed

The first three caller rows depend on which shape you reported above. An integration that reports caller.changed receives every one of those changes as PUT /caller carrying the whole composed caller, and never receives the three PATCH routes. The generic changed operation is only used when the integration reports incident.changed; its body carries no incident data, only event and openUrl. event names the specific operation that would have carried the change, so an integration that implements none of them can decide whether this particular change is worth opening the incident in Portal. A missing generic capability means that an unsupported specific change is not delivered.

Chat and live video are not symmetrical, and that is deliberate. Live video ends on its own, so it has both caller.liveVideo.activated and caller.liveVideo.ended. Chat has no end of its own: once activated it stays available until the incident closes, so there is no caller/chat/ended route and no caller.chat.ended flag. The chat object on the caller is null only until chat is activated; it never returns to null while the incident is open.

For field-change operations, null represents a cleared nullable text value, exactly as the incident snapshot reports it. A blank string is never sent and must not be read as a cleared value. IncidentLiveVideoEndedV3 has no request body.

Every property NG-SOS delivers is present in the body, so each one is required in the contract and a nullable one carries null rather than disappearing. Do not treat a missing key as a cleared value. A variant type such as Location is the one exception: a member that does not exist for the selected type is absent rather than null, which is not the same as a cleared value. The only optional properties in the contract are the ones you declare in your /about response: the supports flags, where a missing flag or group means unsupported, and allowsMonitoring, which defaults to false and is advisory.

Compatibility notes for v1 and v2

The v1 and v2 wire contracts remain compatibility contracts, with one deliberate enum correction: Eed is removed from their position-source values and GoogleEls is added. Apple EED positions were already anonymized before v1/v2 delivery; they are not exposed by this change. Google ELS locations may flow through v1, v2, and v3.

Migration checklist

  1. Keep the v2 implementation available while v3 is being deployed.
  2. Implement the v3 routes and rebuild DTOs from the v3 contract.
  3. Implement /about and /health; report a 3.x version and only the capabilities that are implemented. Decide between the composed caller and the specific caller fields before implementing them — see Choose either the composed caller or the three specific caller fields.
  4. Support the v3 token identity: Publisher, the agency audience, and webhooks.receive on every route.
  5. Replace v2 position handling with source-specific tracks: read the initial position from caller.tracks in the create body, take new locations from POST /caller/track, and apply PUT /caller/track whenever a track is redefined.
  6. Store and process the source-aware caller payload, including which dataSources were used, Apple EED capability metadata, device detections, and the grouped medical data.
  7. Use delivered openUrl values and handle purpose correctly; ask NG-SOS for a Test incident to verify the receiver.
  8. Return v3 from /about to cut over. NG-SOS selects one webhook version; no incident data migration is required.
  9. After v3 is stable in production, remove the v2 event routes while retaining shared discovery and health endpoints.