Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const ACCESS_TOKEN_URL = 'https://accounts.snapchat.com/login/oauth2/access_token'

export const DEFAULT_RETENTION_DAYS = 9999

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,27 +1,8 @@
import { AudienceDestinationDefinition, defaultValues, IntegrationError } from '@segment/actions-core'
import type { Settings, AudienceSettings } from './generated-types'
import syncAudience from './syncAudience'
const ACCESS_TOKEN_URL = 'https://accounts.snapchat.com/login/oauth2/access_token'

interface RefreshTokenResponse {
access_token: string
}
interface SnapAudienceResponse {
segments: {
segment: {
id: string
}
}[]
}
interface CreateAudienceReq {
segments: {
name: string
source_type: string
ad_account_id: string
description: string
retention_in_days: number
}[]
}
import { CreateAudienceReq, RefreshTokenResponse, SnapAudienceResponse } from './types'
import { ACCESS_TOKEN_URL, DEFAULT_RETENTION_DAYS } from './constants'

const destination: AudienceDestinationDefinition<Settings, AudienceSettings> = {
name: 'Snap Audiences (Actions)',
Expand Down Expand Up @@ -93,33 +74,18 @@ const destination: AudienceDestinationDefinition<Settings, AudienceSettings> = {
eventSlug: 'journeys_step_entered_track'
},
{
name: 'Sync Audience with Email',
subscribe: 'type = "track" and context.traits.email exists',
name: 'Sync Engage Audience',
subscribe: 'type = "track" or type = "identify"',
partnerAction: 'syncAudience',
mapping: { ...defaultValues(syncAudience.fields), schema_type: 'EMAIL_SHA256' },
type: 'automatic'
},
{
name: 'Sync Audience with Phone',
subscribe: 'type = "track" and properties.phone exists',
partnerAction: 'syncAudience',
mapping: { ...defaultValues(syncAudience.fields), schema_type: 'PHONE_SHA256' },
type: 'automatic'
},
{
name: 'Sync Audience with Mobile AD ID',
subscribe: 'type = "track" and context.device.advertisingId exists',
partnerAction: 'syncAudience',
mapping: { ...defaultValues(syncAudience.fields), schema_type: 'MOBILE_AD_ID_SHA256' },
mapping: { ...defaultValues(syncAudience.fields)},
type: 'automatic'
}
],
audienceFields: {
customAudienceName: {
type: 'string',
label: 'Audience Name',
description:
'Name for the audience that will be created in Snap. Defaults to the snake_cased Segment audience name if left blank.',
description: 'Name for the audience that will be created in Snap. Defaults to the snake_cased Segment audience name if left blank.',
default: '',
required: false
},
Expand All @@ -133,7 +99,7 @@ const destination: AudienceDestinationDefinition<Settings, AudienceSettings> = {
retention_in_days: {
type: 'number',
label: 'Retention in days',
description: '# of days to retain audience members. (Default retention is lifetime represented as 9999)',
description: 'Number of days to retain audience members. (Default retention is lifetime represented as 9999)',
default: 9999,
required: false
}
Expand All @@ -144,44 +110,50 @@ const destination: AudienceDestinationDefinition<Settings, AudienceSettings> = {
full_audience_sync: false
},
async createAudience(request, createAudienceInput) {
const audienceName = createAudienceInput.audienceName
const ad_account_id = createAudienceInput.settings.ad_account_id
const { customAudienceName, description, retention_in_days } = createAudienceInput.audienceSettings || {}
const {
audienceName,
settings: { ad_account_id } = {},
audienceSettings: {
customAudienceName,
description,
retention_in_days
} = {}
} = createAudienceInput

if (!audienceName) {
throw new IntegrationError('Missing audience name value', 'MISSING_REQUIRED_FIELD', 400)
}

const response = await request(`https://adsapi.snapchat.com/v1/adaccounts/${ad_account_id}/segments`, {
if(!ad_account_id){
throw new IntegrationError('Missing Ad Account ID. Please configure the Ad Account ID in the destination settings.', 'MISSING_REQUIRED_FIELD', 400)
}

const json: CreateAudienceReq = {
segments: [
{
name: customAudienceName || audienceName,
source_type: 'FIRST_PARTY',
ad_account_id,
description: description || `Audience ${audienceName} created by Segment`,
retention_in_days: retention_in_days || DEFAULT_RETENTION_DAYS
}
]
}

const response = await request<SnapAudienceResponse>(`https://adsapi.snapchat.com/v1/adaccounts/${ad_account_id}/segments`, {
method: 'POST',
json: {
segments: [
{
name: customAudienceName !== '' ? customAudienceName : audienceName,
source_type: 'FIRST_PARTY',
ad_account_id,
description,
retention_in_days
}
]
} as CreateAudienceReq
json
})

const data: SnapAudienceResponse = await response.json()
const snapAudienceId = data.segments[0].segment.id

return { externalId: snapAudienceId }
return { externalId: response.data.segments[0].segment.id }
},

getAudience: async (request, { externalId }) => {
const response = await request(`https://adsapi.snapchat.com/v1/segments/${externalId}`, {
const response = await request<SnapAudienceResponse>(`https://adsapi.snapchat.com/v1/segments/${externalId}`, {
method: 'GET'
})

const data: SnapAudienceResponse = await response.json()
const snapAudienceId = data.segments[0].segment.id

return { externalId: snapAudienceId }
return { externalId: response.data.segments[0].segment.id }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import nock from 'nock'
import { createTestEvent, createTestIntegration, PayloadValidationError } from '@segment/actions-core'
import Destination from '../../index'
import { processHashing } from '../../../../lib/hashing-utils'
import { normalize, normalizePhone } from '../utils'
import { normalize, normalizePhone } from '../functions'

const testDestination = createTestIntegration(Destination)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const SCHEMA_TYPES = {
EMAIL: 'EMAIL_SHA256',
PHONE: 'PHONE_SHA256',
MAID: 'MOBILE_AD_ID_SHA256'
} as const
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { InputField } from '@segment/actions-core'

export const fields: Record<string, InputField> = {
external_audience_id: {
type: 'string',
label: 'External Audience ID',
description: 'Unique Audience Identifier returned by the createAudience() function call.',
required: true,
unsafe_hidden: true,
default: {
'@path': '$.context.personas.external_audience_id'
}
},
audienceKey: {
type: 'string',
label: 'Audience Key',
description: 'Audience key.',
required: true,
unsafe_hidden: true,
default: {
'@path': '$.context.personas.computation_key'
}
},
props: {
label: 'Properties object',
description: 'Object that contains audience name and value.',
type: 'object',
required: true,
unsafe_hidden: true,
default: { '@path': '$.properties' }
},
phone: {
label: 'Phone',
description: "If using phone as the identifier an additional setup step is required when connecting the Destination to the Audience. Please ensure that 'phone' is configured as an additional identifier in the Audience settings tab.",
type: 'string',
required: false,
default: { '@path': '$.properties.phone' },
},
email: {
label: 'Email',
description: "The user's email address.",
type: 'string',
required: false,
default: { '@path': '$.properties.email' },
},
advertising_id: {
label: 'Mobile Advertising ID',
description:
"If using Mobile Ad ID as the identifier an additional setup step is required when connecting the Destination to the Audience. Please ensure that 'ios.idfa' is configured to 'ios_idfa' and 'android.idfa' is configured to 'android_idfa' in the Audience settings tab.",
type: 'string',
required: false,
default: {
'@if': {
exists: { '@path': '$.properties.android_idfa' },
then: { '@path': '$.properties.android_idfa' },
else: { '@path': '$.properties.ios_idfa' }
}
}
},
enable_batching: {
label: 'Enable Batching',
description: 'When enabled, events will be batched before being sent to Snap.',
type: 'boolean',
required: true,
default: true
},
max_batch_size: {
label: 'Max Batch Size',
description: 'Maximum number of API calls to include in a batch. Defaults to 100,000 which is the maximum allowed by Snap.',
type: 'number',
required: true,
minimum: 1,
maximum: 100_000,
default: 100_000
},
batch_keys: {
label: 'Batch Keys',
description: 'The keys to use for batching the events. Ensures events from different audiences are sent in separate batches. This is Segment default behavior with Engage Audiences anyway.',
type: 'string',
unsafe_hidden: true,
required: false,
multiple: true,
default: ['external_audience_id']
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import type { Payload } from './generated-types'
import { processHashing } from '../../../lib/hashing-utils'
import { RequestClient, MultiStatusResponse } from '@segment/actions-core'
import { PayloadWithIndex, AddRemoveUsersJSON, SchemaType } from './types'
import { SCHEMA_TYPES } from './constants'


export async function send(request: RequestClient, payload: Payload[]) {
const payloads: PayloadWithIndex[] = payload.map((p, index) => ({ ...p, index }))
const { external_audience_id } = payload[0]
const multiStatusResponse = new MultiStatusResponse()

const grouped = payloads.reduce(
(acc, p) => {
const hasValue = Boolean(p.email || p.phone || p.advertising_id)
if (!hasValue) {
multiStatusResponse.setErrorResponseAtIndex(p.index, {
status: 400,
errortype: 'PAYLOAD_VALIDATION_FAILED',
errormessage: 'One of "email" or "phone" or "Mobile Advertising ID" is required.'
});
return acc
}

const isAdd = Boolean(p.props[p.audienceKey])

if (p.email) (isAdd ? acc.addEmail : acc.removeEmail).push(p)
if (p.phone) (isAdd ? acc.addPhone : acc.removePhone).push(p)
if (p.advertising_id) (isAdd ? acc.addMAID : acc.removeMAID).push(p)

return acc
},
{
addEmail: [] as PayloadWithIndex[],
addPhone: [] as PayloadWithIndex[],
addMAID: [] as PayloadWithIndex[],
removeEmail: [] as PayloadWithIndex[],
removePhone: [] as PayloadWithIndex[],
removeMAID: [] as PayloadWithIndex[],
}
)

const url = `https://adsapi.snapchat.com/v1/segments/${external_audience_id}/users`

return await Promise.all([
sendRequest(request, grouped.addEmail, SCHEMA_TYPES.EMAIL, "POST", url),
sendRequest(request, grouped.addPhone, SCHEMA_TYPES.PHONE, "POST", url),
sendRequest(request, grouped.addMAID, SCHEMA_TYPES.MAID, "POST", url),
sendRequest(request, grouped.removeEmail, SCHEMA_TYPES.EMAIL, "DELETE", url),
sendRequest(request, grouped.removePhone, SCHEMA_TYPES.PHONE, "DELETE", url),
sendRequest(request, grouped.removeMAID, SCHEMA_TYPES.MAID, "DELETE", url)
])

}

async function sendRequest(request: RequestClient, payloads: PayloadWithIndex[], type: SchemaType, method: "POST" | "DELETE", url: string) {
if (payloads.length === 0) return { skipped: true }

const json = buildJSON(payloads, type)
return await request(url, { method, json })
}

function buildJSON(payloads: PayloadWithIndex[], type: SchemaType): AddRemoveUsersJSON {
const data: [string][] = payloads.reduce<[string][]>((acc, p) => {
let value: string | undefined;
if (type === SCHEMA_TYPES.EMAIL && p.email) value = processHashing(p.email, 'sha256', 'hex', normalize);
else if (type === SCHEMA_TYPES.PHONE && p.phone) value = processHashing(p.phone, 'sha256', 'hex', normalizePhone);
else if (type === SCHEMA_TYPES.MAID && p.advertising_id) value = processHashing(p.advertising_id, 'sha256', 'hex', normalize);

if (value) acc.push([value])
return acc
}, [])

return {
users: [
{
schema: [type],
data
}
]
}
}

export const normalize = (identifier: string): string => {
return identifier.trim().toLowerCase()
}

/*
Normalize phone numbers by
- removing any double 0 in front of the country code
- if the number itself begins with a 0 this should be removed
- Also exclude any non-numeric characters such as whitespace, parentheses, '+', or '-'.
*/
export const normalizePhone = (phone: string): string => {
// Remove non-numeric characters and parentheses, '+', '-', ' '
let normalizedPhone = phone.replace(/[\s()+-]/g, '')

// Remove leading "00" if present
if (normalizedPhone.startsWith('00')) {
normalizedPhone = normalizedPhone.substring(2)
}

// Remove leading zero if present (for local numbers)
if (normalizedPhone.startsWith('0')) {
normalizedPhone = normalizedPhone.substring(1)
}

return normalizedPhone
}
Loading
Loading