Push URL Callback
Receive saved email content from the editor through a secure backend callback.
The Push URL callback is how your backend receives saved email content after an End User saves in the editor.
Configure a Push URL before production use. The editor does not return final email HTML through iframe state, browser state, or Return URL. Your backend should treat the Push URL callback as the source of saved email content.
Configure Push URL
Open:
Application -> Editor API -> Push URLEnter an HTTPS endpoint from your backend.
Example:
https://api.yourproduct.com/webhooks/asc-editor-saveYour endpoint must:
- Accept
POSTrequests with a JSON body. - Verify the callback signature before decrypting the payload.
- Decrypt the payload with the Push Key for the Application.
- Store the saved email content idempotently.
- Return a successful
2xxresponse only after processing succeeds. Returning HTTP200is recommended.
Push Key
The Push Key is a shared secret used to derive separate encryption and signature keys for callback requests.
When you reset the Push Key, copy and store the complete value immediately. The complete value includes the asc_editor_ prefix and is returned only by the reset operation. Later configuration queries return a masked value.
Keep the Push Key on your backend only. Do not expose it in frontend code, mobile apps, logs, or public repositories.
Resetting the Push Key takes effect immediately and invalidates the previous key. Update your receiving backend before relying on callbacks encrypted with the new key.
Webhook request sent to your Push URL
This is not an API response. After an End User saves an email, ASC Editor Plugin sends the following POST request to the Push URL configured for your Application.
The request body is an aes_gcm_v2 encrypted JSON envelope:
POST {Push URL}
Content-Type: application/json
Authorization: SS-HMAC-SHA256 signature={signature}
{
"version": "aes_gcm_v2",
"timestamp": 1784167200000,
"requestId": "template-push:{opaqueHash}",
"nonce": "{base64UrlNonce}",
"ciphertext": "{base64UrlCiphertext}"
}| Field | Type | Description |
|---|---|---|
version | string | Encryption protocol version. The current value is aes_gcm_v2. |
timestamp | integer | Callback creation time in Unix milliseconds. |
requestId | string | Stable identifier for idempotent processing of the saved content version. |
nonce | string | Base64Url-encoded 12-byte AES-GCM nonce, without padding. |
ciphertext | string | Base64Url-encoded ciphertext followed by the 128-bit GCM authentication tag, without padding. |
The Authorization header contains an HMAC signature. It does not contain the Push Key or an encrypted key.
Encryption and signature protocol
Use the complete Push Key, including the asc_editor_ prefix, as UTF-8 input key material.
Derive two 32-byte keys with HKDF-SHA256:
| Purpose | HKDF value |
|---|---|
| Salt | UTF-8 bytes of ss-editor-webhook-v2 |
| Encryption key info | UTF-8 bytes of webhook-encryption |
| Signature key info | UTF-8 bytes of webhook-signature |
Build the AES-GCM additional authenticated data, using a single line-feed character (\n) between values and no trailing line feed:
aes_gcm_v2
{timestamp}
{requestId}Decrypt ciphertext with:
- AES-256-GCM (
AES/GCM/NoPaddingin Java). - The derived encryption key.
- The decoded 12-byte
nonce. - A 128-bit authentication tag.
- The value above as GCM additional authenticated data.
The signature is HMAC-SHA256 over the following UTF-8 value:
{AAD}
{nonce}
{ciphertext}Encode the HMAC result with Base64Url without padding. The resulting value is sent as:
Authorization: SS-HMAC-SHA256 signature={signature}Verify the signature with a constant-time comparison before decrypting the ciphertext.
Decrypted payload
After verification and decryption, the plaintext is a JSON object similar to:
{
"id": "79002c83707111f1",
"name": "Welcome Email",
"subject": "Welcome to our platform",
"summary": "A short introduction",
"content": "<!DOCTYPE html><html><body>Welcome</body></html>",
"component": {},
"snapshot": "https://cdn.example.com/snapshot.png",
"userId": "user_12345",
"extra": {
"source": "campaign"
},
"createTime": "2026-07-16 10:00:00",
"updateTime": "2026-07-16 10:15:00",
"timestamp": 1784167200000
}| Field | Description |
|---|---|
id | Public email template slug ID. |
name | Email template name. |
subject | Email subject. |
summary | Email summary. |
content | Saved email HTML. |
component | Saved component configuration when available. |
snapshot | Snapshot URL when available. |
userId | User identifier associated with the save when available. |
extra | Application-defined extra data when available. |
createTime | Email creation time in yyyy-MM-dd HH:mm:ss format. |
updateTime | Email update time in yyyy-MM-dd HH:mm:ss format. |
timestamp | Payload creation time in Unix milliseconds. |
Optional values may be null. The current callback payload does not contain emailId, html, settings, or json fields; use id, content, and component as shown above.
Java 8 verification and decryption example
The following example is embedded so it can be copied directly from this page. It uses Jackson Databind, which is already included by most Spring Boot web applications. For AES-256 on Java 8, use JDK 8u161 or later; older Java 8 versions may require the Unlimited Strength JCE Policy.
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.crypto.Cipher;
import javax.crypto.Mac;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
public final class WebhookDecryptExample {
private static final String VERSION = "aes_gcm_v2";
private static final String AUTH_SCHEME = "SS-HMAC-SHA256";
private static final long MAX_CLOCK_SKEW_MILLIS = 5 * 60 * 1000L;
private static final int GCM_TAG_LENGTH_BITS = 128;
private static final int NONCE_LENGTH_BYTES = 12;
private static final byte[] HKDF_SALT =
"ss-editor-webhook-v2".getBytes(StandardCharsets.UTF_8);
private static final byte[] ENCRYPTION_INFO =
"webhook-encryption".getBytes(StandardCharsets.UTF_8);
private static final byte[] SIGNATURE_INFO =
"webhook-signature".getBytes(StandardCharsets.UTF_8);
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Base64.Decoder BASE64_URL_DECODER = Base64.getUrlDecoder();
private WebhookDecryptExample() {
}
public static VerifiedWebhook verifyAndDecrypt(
String requestBody,
String authorization,
String pushKey
) throws Exception {
if (isBlank(requestBody) || isBlank(authorization) || isBlank(pushKey)) {
throw new IllegalArgumentException(
"requestBody, authorization and pushKey are required");
}
WebhookEnvelope envelope = MAPPER.readValue(requestBody, WebhookEnvelope.class);
validateEnvelope(envelope);
long now = System.currentTimeMillis();
if (envelope.timestamp < now - MAX_CLOCK_SKEW_MILLIS
|| envelope.timestamp > now + MAX_CLOCK_SKEW_MILLIS) {
throw new SecurityException("Webhook timestamp expired");
}
byte[] inputKeyMaterial = pushKey.getBytes(StandardCharsets.UTF_8);
byte[] encryptionKey = hkdfSha256(inputKeyMaterial, ENCRYPTION_INFO);
byte[] signatureKey = hkdfSha256(inputKeyMaterial, SIGNATURE_INFO);
String aad = envelope.version + "\n"
+ envelope.timestamp + "\n"
+ envelope.requestId;
String signatureInput = aad + "\n"
+ envelope.nonce + "\n"
+ envelope.ciphertext;
byte[] expectedSignature = hmacSha256(
signatureKey, signatureInput.getBytes(StandardCharsets.UTF_8));
byte[] actualSignature = decodeBase64Url(
extractSignature(authorization), "signature");
if (!MessageDigest.isEqual(expectedSignature, actualSignature)) {
throw new SecurityException("Invalid webhook signature");
}
byte[] nonce = decodeBase64Url(envelope.nonce, "nonce");
if (nonce.length != NONCE_LENGTH_BYTES) {
throw new SecurityException("Invalid webhook nonce");
}
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(
Cipher.DECRYPT_MODE,
new SecretKeySpec(encryptionKey, "AES"),
new GCMParameterSpec(GCM_TAG_LENGTH_BITS, nonce));
cipher.updateAAD(aad.getBytes(StandardCharsets.UTF_8));
byte[] plaintext = cipher.doFinal(
decodeBase64Url(envelope.ciphertext, "ciphertext"));
return new VerifiedWebhook(
envelope.requestId,
new String(plaintext, StandardCharsets.UTF_8));
}
private static void validateEnvelope(WebhookEnvelope envelope) {
if (envelope == null || !VERSION.equals(envelope.version)) {
throw new SecurityException("Unsupported webhook protocol version");
}
if (envelope.timestamp <= 0
|| isBlank(envelope.requestId)
|| isBlank(envelope.nonce)
|| isBlank(envelope.ciphertext)) {
throw new SecurityException("Invalid webhook envelope");
}
}
private static String extractSignature(String authorization) {
String prefix = AUTH_SCHEME + " ";
if (!authorization.startsWith(prefix)) {
throw new SecurityException("Invalid Authorization scheme");
}
String[] attributes = authorization.substring(prefix.length()).split(",");
for (String attribute : attributes) {
String value = attribute.trim();
if (value.startsWith("signature=")
&& !isBlank(value.substring("signature=".length()))) {
return value.substring("signature=".length());
}
}
throw new SecurityException("Missing webhook signature");
}
private static byte[] hkdfSha256(byte[] inputKeyMaterial, byte[] info)
throws Exception {
// HKDF-Extract: PRK = HMAC(salt, input key material)
byte[] pseudoRandomKey = hmacSha256(HKDF_SALT, inputKeyMaterial);
// A 32-byte output requires only the first HKDF-Expand block.
byte[] expandInput = new byte[info.length + 1];
System.arraycopy(info, 0, expandInput, 0, info.length);
expandInput[info.length] = 1;
return hmacSha256(pseudoRandomKey, expandInput);
}
private static byte[] hmacSha256(byte[] key, byte[] value) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(key, "HmacSHA256"));
return mac.doFinal(value);
}
private static byte[] decodeBase64Url(String value, String fieldName) {
try {
return BASE64_URL_DECODER.decode(value);
} catch (IllegalArgumentException exception) {
throw new SecurityException("Invalid Base64Url " + fieldName, exception);
}
}
private static boolean isBlank(String value) {
return value == null || value.trim().isEmpty();
}
public static final class VerifiedWebhook {
private final String requestId;
private final String plaintext;
public VerifiedWebhook(String requestId, String plaintext) {
this.requestId = requestId;
this.plaintext = plaintext;
}
public String getRequestId() {
return requestId;
}
public String getPlaintext() {
return plaintext;
}
}
public static final class WebhookEnvelope {
public String version;
public long timestamp;
public String requestId;
public String nonce;
public String ciphertext;
public WebhookEnvelope() {
}
}
}A Spring controller can use the helper as follows:
@PostMapping(
value = "/webhooks/asc-editor-save",
consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Void> receiveEditorCallback(
@RequestBody String requestBody,
@RequestHeader("Authorization") String authorization
) throws Exception {
WebhookDecryptExample.VerifiedWebhook webhook =
WebhookDecryptExample.verifyAndDecrypt(
requestBody, authorization, pushKey);
// The plaintext is the saved email JSON shown in "Decrypted payload".
// Parse, validate, and store it according to your own data model.
String plaintext = webhook.getPlaintext();
// TODO: call your application service to persist plaintext before returning.
// requestId identifies this callback content version. Use it only if your
// application needs explicit duplicate-delivery detection.
String requestId = webhook.getRequestId();
return ResponseEntity.ok().build();
}Load pushKey from a backend secret store or environment variable. Do not hard-code it in source code.
This example intentionally stops after verification and decryption. How the plaintext is mapped and stored is determined by your application. Use the decrypted payload id as the public email template identifier; do not use requestId as the email template ID or storage key.
Idempotency and replay protection
The current requestId format is:
template-push:{opaqueHash}The opaque hash is a Base64Url-encoded SHA-256 value derived from the public Application slug, public email template slug, and the business payload excluding timestamp. None of those values can be extracted from the requestId.
Treat the complete requestId as an opaque value. Do not parse it or construct it in your application. A retry for unchanged saved content uses the same requestId, but its envelope timestamp, nonce, ciphertext, and signature can differ.
Callback delivery may be repeated. Design your save operation so repeated delivery does not produce an incorrect result. If your application needs explicit duplicate-delivery detection, it may store and compare requestId; the exact persistence strategy is up to your application.
Also reject callbacks whose millisecond timestamp falls outside an acceptable clock-skew window. A five-minute window is recommended.
Do not use userId or timestamp as the idempotency key.
Expected response and retry behavior
Return a 2xx response only after signature verification, decryption, and storage succeed. HTTP 200 OK is recommended:
HTTP/1.1 200 OKFor normal email update flows, a failed callback may be retried asynchronously up to four times after the initial delivery attempt. Retry timing is not guaranteed, and some save operations may not be eligible for retry.
A retry reloads the latest saved email content before creating a new encrypted request:
- If the saved content is unchanged,
requestIdremains the same whiletimestamp,nonce,ciphertext, and signature change. - If the saved content has changed, the retry delivers the latest content with a new
requestId.
Your receiver must support repeated callback delivery. Do not rely on a callback being delivered exactly once or retried at a fixed interval.
Testing
Before production use:
- Configure an HTTPS Push URL.
- Reset the Push Key and store the complete returned value on your backend.
- Open the editor with a test End User and save an email.
- Confirm the endpoint receives an
application/jsonenvelope and anSS-HMAC-SHA256Authorization header. - Verify the signature before decrypting the payload.
- Confirm the decrypted payload contains the expected
idandcontent. - Confirm a repeated
requestIddoes not apply the update twice. - Return HTTP
200after successful processing.
Updated 9 days ago
